Links
Create, list, change and delete short links, in ones or in bulk.
A link has a destination, a back-half (alias) on a domain, and optional title, tags, campaign, schedule and password. Links are identified by their id, a 12-character string.
Every path below starts with https://bgum.me/api/v1/workspaces/{workspaceId}. Send your API key in the Authorization header as described in the API overview.
| Operation | Method and path |
|---|---|
| Create a link | POST /links |
| List links | GET /links |
| Get a link | GET /links/{linkId} |
| Change a link | PATCH /links/{linkId} |
| Delete a link | DELETE /links/{linkId} |
| Create links in bulk | POST /links/bulk |
| Change many links | POST /links/bulk-actions |
| Check a back-half | GET /aliases/availability |
Create a link
POST /links
Creates a short link. Leave out alias to get a random back-half. Each new link uses one of the organisation's monthly allowance.
- Full path
/api/v1/workspaces/{workspaceId}/links- API key scope
-
links:write - Role needed
- Manager or above
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
workspaceId |
string | Yes | Your workspace ID, from Settings → Workspace. |
Headers
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key |
string | No | Any unique value up to 200 characters, such as a UUID. Repeating a request with the same key within 48 hours returns the first result instead of creating a duplicate or using more allowance. |
Request body
JSON (Content-Type: application/json). Leave out fields you don't need.
| Name | Type | Required | Description |
|---|---|---|---|
destinationUrl |
string | Yes | Where the link goes: an http or https address, up to 4,096 characters. A bare address such as example.com/page gets https:// added. |
domainId |
string | No | ID of an active custom domain from GET /domains (Business). Leave out for the default short domain. |
alias |
string | No | Your own back-half (Pro and above): 3 to 64 letters, numbers, hyphens or underscores, not starting or ending with - or _. Case doesn't matter: Sale and sale are the same link. |
title |
string | No | A name to recognise the link by. Longer than 500 characters is cut. |
description |
string | No | Longer description, up to 2,000 characters. |
notes |
string | No | Private notes for your team. |
tags |
string[] | No | Up to 20 tags of up to 50 characters. New tags are created as needed; case doesn't matter. |
campaignId |
string | No | ID of an active campaign (Pro and above). Its UTM tags are added when people follow the link. |
folderId |
string | No | ID of a folder in the workspace, from GET /folders. Leave out for no folder. |
startsAt |
date-time | No | When the link starts working, in ISO 8601 with a time zone, e.g. 2026-11-01T09:00:00+11:00 (Pro and above). |
endsAt |
date-time | No | When the link stops working and shows a "link expired" page. Must be after startsAt (Pro and above). |
draft |
boolean | No | true creates the link as a draft that doesn't redirect until you activate it. Default false. |
redirectType |
string | No | Found (302, the default), MovedPermanently (301) or TemporaryRedirect (307). The number also works, e.g. 301. |
password |
string | No | Visitors must enter this password first (Pro and above). At least 4 characters. |
trackConversions |
boolean | No | true adds a click ID (bgl_click) to the destination so your site can report sign-ups and sales (Business). Default false. See Conversions. |
routing |
object | No | Smart routing (Business): rules by country, device or language, a traffic split, and app links. See the notes below for its shape. |
maxClicks |
integer | No | The link expires after this many clicks from people (Pro and above), from 1 to 1,000,000,000. |
expiredRedirectUrl |
string | No | Where people go once the link has expired (stop time or click limit), instead of the "link expired" page (Pro and above). Same rules and safety check as destinationUrl. |
idempotencyKey |
string | No | Same as the Idempotency-Key header. The header wins if both are sent. |
Example request
curl
curl -X POST "https://bgum.me/api/v1/workspaces/$WORKSPACE_ID/links" \
-H "Authorization: Bearer $BGL_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"destinationUrl": "https://example.com/spring-sale",
"alias": "spring-sale",
"title": "Spring sale",
"tags": [
"email",
"spring"
],
"campaignId": "aiQrz1XgekFP"
}'
JavaScript
const res = await fetch(`https://bgum.me/api/v1/workspaces/${process.env.WORKSPACE_ID}/links`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BGL_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
"destinationUrl": "https://example.com/spring-sale",
"alias": "spring-sale",
"title": "Spring sale",
"tags": [
"email",
"spring"
],
"campaignId": "aiQrz1XgekFP"
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);
Python
import os
import requests
import uuid
res = requests.post(
f"https://bgum.me/api/v1/workspaces/{os.environ['WORKSPACE_ID']}/links",
headers={
"Authorization": f"Bearer {os.environ['BGL_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"destinationUrl": "https://example.com/spring-sale",
"alias": "spring-sale",
"title": "Spring sale",
"tags": ["email", "spring"],
"campaignId": "aiQrz1XgekFP",
},
timeout=30,
)
res.raise_for_status()
print(res.json())
Response
201 Created · application/json
JSON
{
"id": "ZiZihoWXe3as",
"shortUrl": "https://bgum.me/spring-sale",
"domain": "bgum.me",
"alias": "spring-sale",
"isCustomAlias": true,
"destinationUrl": "https://example.com/spring-sale",
"title": "Spring sale",
"description": null,
"notes": null,
"status": "Active",
"state": "Active",
"startsAt": null,
"endsAt": null,
"redirectType": "Found",
"passwordProtected": false,
"tags": [
"email",
"spring"
],
"campaignId": "aiQrz1XgekFP",
"version": 1,
"createdAt": "2026-10-14T22:49:34.114896+00:00",
"updatedAt": "2026-10-14T22:49:34.114896+00:00",
"totalClicks": 0,
"blocked": false,
"trackConversions": false,
"folderId": "q5Tz0aLmW2xd",
"routing": null,
"maxClicks": null,
"clickLimitReached": false,
"expiredRedirectUrl": null
}
Good to know
- Returns 201 with a Location header for a new link. A repeat with the same Idempotency-Key returns 200 and the original link.
- When the monthly allowance is used up the response is 429 with a Retry-After header (seconds) and resetsAt in the body.
- The account that owns the key must have confirmed its email address.
- routing has three optional parts. rules: up to 20, checked in order; the first rule whose conditions all match sends the visitor to its destinationUrl. Each rule needs at least one of countries (two-letter codes such as AU), devices (ios, android, windows, macos, other) or languages (the browser's preferred language, such as en). split: 2 to 5 destinations with whole-number weights from 1 to 1,000, sharing everyone no rule matched; the same visitor keeps getting the same one. app: iosUrl and androidUrl, your app's own link (myapp://…), an Android intent: link or an https link; phones try the app first and fall back to the web destination. See Smart routing in the help centre.
- Each rule and split destination has an id (up to 12 lower-case letters and digits). Leave it out and one is made up. Send the ids back when you change routing, so analytics keep counting each rule and destination as the same one.
- Every destination in routing is checked like destinationUrl, including the safety check. Smart routing needs the Business plan; maxClicks needs Pro or above.
Errors
| Status | Code | Meaning |
|---|---|---|
| 422 | destination.required | destinationUrl is missing or empty. |
| 422 | destination.invalid | The destination isn't a valid web address. |
| 422 | destination.scheme | Only http and https addresses can be shortened. |
| 422 | destination.credentials | The address contains a username or password (user:pass@host). |
| 422 | destination.private | The address points to a private network, localhost or an internal name. |
| 422 | destination.loop | The address is itself a short link on this service, which would loop. |
| 422 | destination.too_long | The destination is longer than 4,096 characters. |
| 422 | link.destination_unsafe | A threat-intelligence service lists the destination as phishing or malware, so it can't be used. provider and threat say which. |
| 422 | alias.too_short | The back-half is shorter than the minimum (usually 3 characters). |
| 422 | alias.too_long | The back-half is longer than the maximum (usually 64 characters). |
| 422 | alias.invalid_characters | The back-half has characters other than letters, numbers, hyphens and underscores. |
| 422 | alias.invalid_edges | The back-half starts or ends with a hyphen or underscore. |
| 409 | alias.reserved | The back-half is a word this service uses itself, such as admin or docs. |
| 422 | alias.prohibited | The back-half contains a word that isn't allowed. |
| 409 | alias.taken | Another link on that domain already uses the back-half, including archived and deleted links. |
| 409 | alias.generation_failed | We couldn't generate a unique random back-half. |
| 404 | domain.not_found | No domain with that ID in this workspace, or it isn't available to it. |
| 409 | domain.not_ready | The domain isn't verified and active yet, so links can't use it. |
| 404 | campaign.not_found | No campaign with that ID in this workspace. When you add a link to a campaign, an archived one counts as missing. |
| 404 | folder.not_found | No folder with that ID in this workspace. |
| 422 | link.schedule_invalid | The stop time isn't after the start time. |
| 422 | link.password_too_short | Link passwords need at least 4 characters. |
| 422 | link.redirect_type_invalid | redirectType isn't one of the allowed values. |
| 422 | tags.too_many | More than 20 tags on one link. |
| 422 | tags.too_long | A tag is longer than 50 characters. |
| 422 | idempotency.key_too_long | The Idempotency-Key is longer than 200 characters. |
| 409 | idempotency.key_reused | The Idempotency-Key was already used for a different request in the last 48 hours. |
| 409 | idempotency.target_deleted | The link created by this Idempotency-Key has since been deleted. |
| 429 | quota.links_exhausted | The organisation has used this month's allowance of new links. The response has limit and resetsAt fields and a Retry-After header. |
| 409 | platform.no_default_domain | The service has no default short domain set up yet. |
| 422 | routing.too_many_rules | routing has more than 20 rules. |
| 422 | routing.rule_invalid | A rule in routing.rules is null. |
| 422 | routing.rule_condition_required | A rule has no countries, devices or languages, so it would catch everyone. |
| 422 | routing.too_many_countries | A rule lists more than 60 countries. |
| 422 | routing.country_invalid | A country isn't a two-letter ISO 3166 code. |
| 422 | routing.device_invalid | A device isn't one of ios, android, windows, macos or other. |
| 422 | routing.too_many_languages | A rule lists more than 20 languages. |
| 422 | routing.language_invalid | A language isn't a two- or three-letter code. |
| 422 | routing.split_size | routing.split has one destination, or more than five. |
| 422 | routing.variant_invalid | A destination in routing.split is null. |
| 422 | routing.weight_invalid | A split weight isn't a whole number from 1 to 1,000. |
| 422 | routing.name_too_long | A rule or split name is longer than 100 characters. |
| 422 | routing.id_invalid | A rule or split id isn't up to 12 lower-case letters and digits. |
| 422 | routing.id_duplicate | Two rules or split destinations have the same id. |
| 422 | routing.app_url_invalid | An app link isn't an app address (myapp://…), an Android intent: link or an https address. |
| 422 | link.max_clicks_invalid | maxClicks isn't a whole number from 1 to 1,000,000,000. |
Any request can also return the general errors: 401 for a missing or wrong key, 403 for a missing scope or plan feature, 404 for an unknown workspace and 429 when you're sending too fast.
List links
GET /links
Lists the workspace's links one page at a time: newest first, or best match first when searching. Archived links are left out unless you ask for them with state=Archived.
- Full path
/api/v1/workspaces/{workspaceId}/links- API key scope
-
links:read - Role needed
- Viewer or above
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
workspaceId |
string | Yes | Your workspace ID, from Settings → Workspace. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
search |
string | No | Words to find in the back-half, title, description, notes, destination or tags. Each word matches the start of a word (camp finds Campaign) and all must match, in any order. Part of a back-half also matches. Case and punctuation don't matter. |
state |
string | No | Only links in this state: Draft, Scheduled, Active, Paused, Expired or Archived. |
tag |
string | No | Only links with this tag. |
campaignId |
string | No | Only links in this campaign. |
domainId |
string | No | Only links on this domain. |
folderId |
string | No | Only links directly in this folder (not its subfolders). none gives links that aren't in a folder. |
createdFrom |
date-time | No | Only links created at or after this time, in ISO 8601 with a time zone, e.g. 2026-10-01T00:00:00Z. |
createdTo |
date-time | No | Only links created before this time. |
sort |
string | No | relevance (best match first, the default with search), -created (newest first, the default otherwise), created, -updated, updated or alias. |
page |
integer | No | Page number, from 1. Default 1. |
pageSize |
integer | No | Links per page, 1 to 100. Default 25. |
Example request
curl
curl "https://bgum.me/api/v1/workspaces/$WORKSPACE_ID/links?search=spring&state=Active&pageSize=50" \
-H "Authorization: Bearer $BGL_API_KEY"
JavaScript
const res = await fetch(`https://bgum.me/api/v1/workspaces/${process.env.WORKSPACE_ID}/links?search=spring&state=Active&pageSize=50`, {
headers: {
Authorization: `Bearer ${process.env.BGL_API_KEY}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);
Python
import os
import requests
res = requests.get(
f"https://bgum.me/api/v1/workspaces/{os.environ['WORKSPACE_ID']}/links?search=spring&state=Active&pageSize=50",
headers={
"Authorization": f"Bearer {os.environ['BGL_API_KEY']}",
},
timeout=30,
)
res.raise_for_status()
print(res.json())
Response
200 OK · application/json
JSON
{
"items": [
{
"id": "ZiZihoWXe3as",
"shortUrl": "https://bgum.me/spring-sale",
"domain": "bgum.me",
"alias": "spring-sale",
"isCustomAlias": true,
"destinationUrl": "https://example.com/spring-sale",
"title": "Spring sale",
"description": null,
"notes": null,
"status": "Active",
"state": "Active",
"startsAt": null,
"endsAt": null,
"redirectType": "Found",
"passwordProtected": false,
"tags": [
"email",
"spring"
],
"campaignId": "aiQrz1XgekFP",
"version": 1,
"createdAt": "2026-10-14T22:49:34.114896+00:00",
"updatedAt": "2026-10-14T22:49:34.114896+00:00",
"totalClicks": 0,
"blocked": false,
"trackConversions": false,
"folderId": "q5Tz0aLmW2xd",
"routing": null,
"maxClicks": null,
"clickLimitReached": false,
"expiredRedirectUrl": null
}
],
"page": 1,
"pageSize": 50,
"total": 1
}
Good to know
- total is the number of matching links across all pages. Keep requesting the next page until you've seen total items or a page comes back empty.
- To count the links a filter matches (for example before a bulk action), ask for pageSize=1 and read total.
Errors
Any request can also return the general errors: 401 for a missing or wrong key, 403 for a missing scope or plan feature, 404 for an unknown workspace and 429 when you're sending too fast.
Get a link
GET /links/{linkId}
Returns one link, including its total clicks.
- Full path
/api/v1/workspaces/{workspaceId}/links/{linkId}- API key scope
-
links:read - Role needed
- Viewer or above
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
workspaceId |
string | Yes | Your workspace ID, from Settings → Workspace. |
linkId |
string | Yes | The link's ID, as returned when it was created or listed. |
Example request
curl
curl "https://bgum.me/api/v1/workspaces/$WORKSPACE_ID/links/ZiZihoWXe3as" \
-H "Authorization: Bearer $BGL_API_KEY"
JavaScript
const res = await fetch(`https://bgum.me/api/v1/workspaces/${process.env.WORKSPACE_ID}/links/ZiZihoWXe3as`, {
headers: {
Authorization: `Bearer ${process.env.BGL_API_KEY}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);
Python
import os
import requests
res = requests.get(
f"https://bgum.me/api/v1/workspaces/{os.environ['WORKSPACE_ID']}/links/ZiZihoWXe3as",
headers={
"Authorization": f"Bearer {os.environ['BGL_API_KEY']}",
},
timeout=30,
)
res.raise_for_status()
print(res.json())
Response
200 OK · application/json
JSON
{
"id": "ZiZihoWXe3as",
"shortUrl": "https://bgum.me/spring-sale",
"domain": "bgum.me",
"alias": "spring-sale",
"isCustomAlias": true,
"destinationUrl": "https://example.com/spring-sale",
"title": "Spring sale",
"description": null,
"notes": null,
"status": "Active",
"state": "Active",
"startsAt": null,
"endsAt": null,
"redirectType": "Found",
"passwordProtected": false,
"tags": [
"email",
"spring"
],
"campaignId": "aiQrz1XgekFP",
"version": 1,
"createdAt": "2026-10-14T22:49:34.114896+00:00",
"updatedAt": "2026-10-14T22:49:34.114896+00:00",
"totalClicks": 0,
"blocked": false,
"trackConversions": false,
"folderId": "q5Tz0aLmW2xd",
"routing": null,
"maxClicks": null,
"clickLimitReached": false,
"expiredRedirectUrl": null
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | link.not_found | No link with that ID in this workspace (or it was deleted). |
Any request can also return the general errors: 401 for a missing or wrong key, 403 for a missing scope or plan feature, 404 for an unknown workspace and 429 when you're sending too fast.
Change a link
PATCH /links/{linkId}
Changes only the fields you send. Changing a link never uses your monthly allowance. The redirect changes straight away.
- Full path
/api/v1/workspaces/{workspaceId}/links/{linkId}- API key scope
-
links:write - Role needed
- Manager or above
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
workspaceId |
string | Yes | Your workspace ID, from Settings → Workspace. |
linkId |
string | Yes | The link's ID, as returned when it was created or listed. |
Request body
JSON (Content-Type: application/json). Leave out fields you don't need.
| Name | Type | Required | Description |
|---|---|---|---|
destinationUrl |
string | No | New destination. Same rules as when creating. |
title |
string | No | New title. Send an empty string to remove it. |
description |
string | No | New description. Send an empty string to remove it. |
notes |
string | No | New notes. |
tags |
string[] | No | Replaces all tags. Send [] to remove them. |
campaignId |
string | No | Moves the link to this campaign. |
clearCampaign |
boolean | No | true takes the link out of its campaign. |
folderId |
string | No | Moves the link into this folder. |
clearFolder |
boolean | No | true takes the link out of its folder. |
startsAt |
date-time | No | New start time (Pro and above). |
endsAt |
date-time | No | New stop time (Pro and above). |
clearSchedule |
boolean | No | true removes both start and stop times. |
status |
string | No | Active, Paused, Archived or Draft. Allowed changes: Draft → Active or Archived; Active ↔ Paused; Active or Paused → Archived; Archived → Paused. |
redirectType |
string | No | Found, MovedPermanently or TemporaryRedirect. |
password |
string | No | Sets or replaces the password (Pro and above). |
removePassword |
boolean | No | true removes the password. |
trackConversions |
boolean | No | true turns conversion tracking on (Business), false turns it off. |
routing |
object | No | Replaces the link's smart routing (Business). Send {} or use clearRouting to remove it. Same shape as when creating. |
clearRouting |
boolean | No | true removes smart routing. Works on any plan. |
maxClicks |
integer | No | Sets or changes the click limit (Pro and above). Raising it above the clicks counted so far brings an expired link back; lowering it below them expires the link now. |
clearMaxClicks |
boolean | No | true removes the click limit. |
expiredRedirectUrl |
string | No | Sets or changes where people go once the link has expired (Pro and above). |
clearExpiredRedirectUrl |
boolean | No | true removes it: an expired link shows the "link expired" page again, or the domain's choice. |
expectedVersion |
integer | No | The version you last read. If the link has changed since, you get 409 link.version_conflict instead of overwriting someone else's change. |
Example request
curl
curl -X PATCH "https://bgum.me/api/v1/workspaces/$WORKSPACE_ID/links/ZiZihoWXe3as" \
-H "Authorization: Bearer $BGL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"destinationUrl": "https://example.com/spring-sale-2026",
"expectedVersion": 1
}'
JavaScript
const res = await fetch(`https://bgum.me/api/v1/workspaces/${process.env.WORKSPACE_ID}/links/ZiZihoWXe3as`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.BGL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"destinationUrl": "https://example.com/spring-sale-2026",
"expectedVersion": 1
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);
Python
import os
import requests
res = requests.patch(
f"https://bgum.me/api/v1/workspaces/{os.environ['WORKSPACE_ID']}/links/ZiZihoWXe3as",
headers={
"Authorization": f"Bearer {os.environ['BGL_API_KEY']}",
},
json={
"destinationUrl": "https://example.com/spring-sale-2026",
"expectedVersion": 1,
},
timeout=30,
)
res.raise_for_status()
print(res.json())
Response
200 OK · application/json
JSON
{
"id": "ZiZihoWXe3as",
"shortUrl": "https://bgum.me/spring-sale",
"domain": "bgum.me",
"alias": "spring-sale",
"isCustomAlias": true,
"destinationUrl": "https://example.com/spring-sale-2026",
"title": "Spring sale",
"description": null,
"notes": null,
"status": "Active",
"state": "Active",
"startsAt": null,
"endsAt": null,
"redirectType": "Found",
"passwordProtected": false,
"tags": [
"email",
"spring"
],
"campaignId": "aiQrz1XgekFP",
"version": 2,
"createdAt": "2026-10-14T22:49:34.114896+00:00",
"updatedAt": "2026-10-15T01:12:40.5862714+00:00",
"totalClicks": 0,
"blocked": false,
"trackConversions": false,
"folderId": "q5Tz0aLmW2xd",
"routing": null,
"maxClicks": null,
"clickLimitReached": false,
"expiredRedirectUrl": null
}
Good to know
- The back-half and domain can't be changed: create a new link instead, so printed or shared copies never change meaning.
- Each change adds a version; the link page in the app shows the history.
- routing has three optional parts. rules: up to 20, checked in order; the first rule whose conditions all match sends the visitor to its destinationUrl. Each rule needs at least one of countries (two-letter codes such as AU), devices (ios, android, windows, macos, other) or languages (the browser's preferred language, such as en). split: 2 to 5 destinations with whole-number weights from 1 to 1,000, sharing everyone no rule matched; the same visitor keeps getting the same one. app: iosUrl and androidUrl, your app's own link (myapp://…), an Android intent: link or an https link; phones try the app first and fall back to the web destination. See Smart routing in the help centre.
- Each rule and split destination has an id (up to 12 lower-case letters and digits). Leave it out and one is made up. Send the ids back when you change routing, so analytics keep counting each rule and destination as the same one.
- Every destination in routing is checked like destinationUrl, including the safety check. Smart routing needs the Business plan; maxClicks needs Pro or above.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | link.not_found | No link with that ID in this workspace (or it was deleted). |
| 403 | link.blocked | The link was disabled after an abuse report, so it can't be changed. |
| 409 | link.version_conflict | expectedVersion doesn't match: someone changed the link since you read it. |
| 409 | link.invalid_transition | The link can't move from its current status to the one you asked for. |
| 422 | link.schedule_invalid | The stop time isn't after the start time. |
| 422 | link.password_too_short | Link passwords need at least 4 characters. |
| 422 | link.redirect_type_invalid | redirectType isn't one of the allowed values. |
| 422 | destination.invalid | The destination isn't a valid web address. |
| 422 | destination.scheme | Only http and https addresses can be shortened. |
| 422 | destination.private | The address points to a private network, localhost or an internal name. |
| 422 | destination.loop | The address is itself a short link on this service, which would loop. |
| 422 | link.destination_unsafe | A threat-intelligence service lists the destination as phishing or malware, so it can't be used. provider and threat say which. |
| 404 | campaign.not_found | No campaign with that ID in this workspace. When you add a link to a campaign, an archived one counts as missing. |
| 404 | folder.not_found | No folder with that ID in this workspace. |
| 422 | tags.too_many | More than 20 tags on one link. |
| 422 | tags.too_long | A tag is longer than 50 characters. |
| 422 | routing.too_many_rules | routing has more than 20 rules. |
| 422 | routing.rule_invalid | A rule in routing.rules is null. |
| 422 | routing.rule_condition_required | A rule has no countries, devices or languages, so it would catch everyone. |
| 422 | routing.too_many_countries | A rule lists more than 60 countries. |
| 422 | routing.country_invalid | A country isn't a two-letter ISO 3166 code. |
| 422 | routing.device_invalid | A device isn't one of ios, android, windows, macos or other. |
| 422 | routing.too_many_languages | A rule lists more than 20 languages. |
| 422 | routing.language_invalid | A language isn't a two- or three-letter code. |
| 422 | routing.split_size | routing.split has one destination, or more than five. |
| 422 | routing.variant_invalid | A destination in routing.split is null. |
| 422 | routing.weight_invalid | A split weight isn't a whole number from 1 to 1,000. |
| 422 | routing.name_too_long | A rule or split name is longer than 100 characters. |
| 422 | routing.id_invalid | A rule or split id isn't up to 12 lower-case letters and digits. |
| 422 | routing.id_duplicate | Two rules or split destinations have the same id. |
| 422 | routing.app_url_invalid | An app link isn't an app address (myapp://…), an Android intent: link or an https address. |
| 422 | link.max_clicks_invalid | maxClicks isn't a whole number from 1 to 1,000,000,000. |
Any request can also return the general errors: 401 for a missing or wrong key, 403 for a missing scope or plan feature, 404 for an unknown workspace and 429 when you're sending too fast.
Delete a link
DELETE /links/{linkId}
Deletes the link. It stops redirecting straight away and its back-half is never reused, so old copies can't be taken over.
- Full path
/api/v1/workspaces/{workspaceId}/links/{linkId}- API key scope
-
links:write - Role needed
- Manager or above
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
workspaceId |
string | Yes | Your workspace ID, from Settings → Workspace. |
linkId |
string | Yes | The link's ID, as returned when it was created or listed. |
Example request
curl
curl -X DELETE "https://bgum.me/api/v1/workspaces/$WORKSPACE_ID/links/ZiZihoWXe3as" \
-H "Authorization: Bearer $BGL_API_KEY"
JavaScript
const res = await fetch(`https://bgum.me/api/v1/workspaces/${process.env.WORKSPACE_ID}/links/ZiZihoWXe3as`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.BGL_API_KEY}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
// 204 No Content: nothing to read.
Python
import os
import requests
res = requests.delete(
f"https://bgum.me/api/v1/workspaces/{os.environ['WORKSPACE_ID']}/links/ZiZihoWXe3as",
headers={
"Authorization": f"Bearer {os.environ['BGL_API_KEY']}",
},
timeout=30,
)
res.raise_for_status()
# 204 No Content: nothing to read.
Response
204 No Content
Good to know
- Deleting doesn't give back the monthly allowance. To stop a link temporarily, PATCH its status to Paused instead.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | link.not_found | No link with that ID in this workspace (or it was deleted). |
Any request can also return the general errors: 401 for a missing or wrong key, 403 for a missing scope or plan feature, 404 for an unknown workspace and 429 when you're sending too fast.
Create links in bulk
POST /links/bulk
Creates many links in one request (Business). Each link is created on its own: one bad row doesn't stop the others.
- Full path
/api/v1/workspaces/{workspaceId}/links/bulk- API key scope
-
links:write - Role needed
- Manager or above
- Plan
- Needs bulk operations in your plan (compare plans)
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
workspaceId |
string | Yes | Your workspace ID, from Settings → Workspace. |
Headers
| Name | Type | Required | Description |
|---|---|---|---|
Idempotency-Key |
string | No | Recommended. Sending the same key again returns the links already made instead of making more. Without a key, an identical request body counts as the same batch. |
Request body
JSON (Content-Type: application/json). Leave out fields you don't need.
| Name | Type | Required | Description |
|---|---|---|---|
links |
object[] | Yes | The links to create, each with the same fields as Create a link (idempotencyKey is ignored). Up to 1,000 on Business. |
Example request
curl
curl -X POST "https://bgum.me/api/v1/workspaces/$WORKSPACE_ID/links/bulk" \
-H "Authorization: Bearer $BGL_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"links": [
{
"destinationUrl": "https://example.com/a",
"title": "Page A"
},
{
"destinationUrl": "https://example.com/b",
"alias": "page-b"
}
]
}'
JavaScript
const res = await fetch(`https://bgum.me/api/v1/workspaces/${process.env.WORKSPACE_ID}/links/bulk`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BGL_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
"links": [
{
"destinationUrl": "https://example.com/a",
"title": "Page A"
},
{
"destinationUrl": "https://example.com/b",
"alias": "page-b"
}
]
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);
Python
import os
import requests
import uuid
res = requests.post(
f"https://bgum.me/api/v1/workspaces/{os.environ['WORKSPACE_ID']}/links/bulk",
headers={
"Authorization": f"Bearer {os.environ['BGL_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"links": [
{
"destinationUrl": "https://example.com/a",
"title": "Page A",
},
{
"destinationUrl": "https://example.com/b",
"alias": "page-b",
},
],
},
timeout=30,
)
res.raise_for_status()
print(res.json())
Response
200 OK · application/json
JSON
{
"rows": [
{
"line": 1,
"destination": "https://example.com/a",
"outcome": "created",
"shortUrl": "https://bgum.me/a2ruepj",
"linkId": "ZGqD6yhrCyac",
"error": null,
"sourceShortUrl": null,
"note": null
},
{
"line": 2,
"destination": "https://example.com/b",
"outcome": "failed",
"shortUrl": null,
"linkId": null,
"error": "“page-b” is already in use on this domain. Try another back-half.",
"sourceShortUrl": null,
"note": null
}
],
"created": 1,
"alreadyCreated": 0,
"failed": 1,
"notAttempted": 0
}
Good to know
- outcome is created, already created (from an earlier attempt with the same key), failed (error says why) or not attempted.
- If the monthly allowance runs out part-way, the remaining rows are not attempted. Retry later with the same Idempotency-Key to finish the batch without duplicates.
Errors
| Status | Code | Meaning |
|---|---|---|
| 422 | bulk.empty | The bulk request had no links in it. |
| 429 | bulk.too_many | The bulk request has more links than your plan allows in one go (1,000 on Business). |
Any request can also return the general errors: 401 for a missing or wrong key, 403 for a missing scope or plan feature, 404 for an unknown workspace and 429 when you're sending too fast.
Change many links
POST /links/bulk-actions
Moves, tags, pauses, resumes, sets or removes stop times on, or deletes many links in one request: the links you list, or every link matching a filter. Works on every plan.
- Full path
/api/v1/workspaces/{workspaceId}/links/bulk-actions- API key scope
-
links:write - Role needed
- Manager or above
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
workspaceId |
string | Yes | Your workspace ID, from Settings → Workspace. |
Request body
JSON (Content-Type: application/json). Leave out fields you don't need.
| Name | Type | Required | Description |
|---|---|---|---|
action |
string | Yes | move, addTags, removeTags, pause, resume, setExpiry, clearExpiry or delete. delete needs permission to delete links; setExpiry and clearExpiry need Pro or above. |
linkIds |
string[] | No | The links to change, up to 1,000. Send this or filter. |
filter |
object | No | Change every link matching these, as in List links: search, state, tag, folderId, domainId, campaignId, createdFrom and createdTo. {} means every link that isn't archived. Up to 10,000 links. |
folderId |
string | No | For move: the folder to move to. Leave out or send null to take the links out of their folders. |
tags |
string[] | No | For addTags and removeTags: the tags. New tags are created as needed. |
endsAt |
date-time | No | For setExpiry: when the links stop working. |
expectedCount |
integer | No | With filter: how many links you expect it to match, such as the total you showed someone to confirm. If it matches a different number now, nothing changes. |
Example request
curl
curl -X POST "https://bgum.me/api/v1/workspaces/$WORKSPACE_ID/links/bulk-actions" \
-H "Authorization: Bearer $BGL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "pause",
"filter": {
"search": "spring",
"state": "Active"
},
"expectedCount": 23
}'
JavaScript
const res = await fetch(`https://bgum.me/api/v1/workspaces/${process.env.WORKSPACE_ID}/links/bulk-actions`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BGL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"action": "pause",
"filter": {
"search": "spring",
"state": "Active"
},
"expectedCount": 23
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);
Python
import os
import requests
res = requests.post(
f"https://bgum.me/api/v1/workspaces/{os.environ['WORKSPACE_ID']}/links/bulk-actions",
headers={
"Authorization": f"Bearer {os.environ['BGL_API_KEY']}",
},
json={
"action": "pause",
"filter": {
"search": "spring",
"state": "Active",
},
"expectedCount": 23,
},
timeout=30,
)
res.raise_for_status()
print(res.json())
Response
200 OK · application/json
JSON
{
"action": "pause",
"matched": 23,
"changed": 22,
"unchanged": 0,
"failed": [
{
"linkId": "ZiZihoWXe3as",
"code": "link.blocked",
"message": "This link was disabled after an abuse report or a safety check, so it can't be changed. Contact support if you think this is a mistake."
}
]
}
Good to know
- unchanged counts links already as asked, such as pausing a paused link. failed lists links that weren't changed and why: listed IDs not found in this workspace (link.not_found), links disabled by a safety check (link.blocked), a link that would get more than 20 tags, or a stop time before its start time.
- Links are changed in batches of 200. If someone changes one of a batch at the same moment, that batch is left as it was and its links are listed in failed with link.version_conflict: send them again.
- Pausing, resuming, stop times and deleting reach the short links straight away and are added to each link's history.
Errors
| Status | Code | Meaning |
|---|---|---|
| 422 | bulk.action_invalid | action isn't one of move, addTags, removeTags, pause, resume, setExpiry, clearExpiry or delete. |
| 422 | bulk.selection_invalid | The request sent both linkIds and filter, or neither, or an empty linkIds. |
| 422 | bulk.too_many_ids | linkIds has more than 1,000 IDs. |
| 409 | bulk.too_many_matches | The filter matches more than 10,000 links. |
| 409 | bulk.count_changed | The filter now matches a different number of links than expectedCount, so nothing was changed. count says how many it matches now. |
| 422 | bulk.tags_required | addTags or removeTags was sent without any tags. |
| 422 | bulk.ends_at_required | setExpiry was sent without endsAt. |
| 404 | folder.not_found | No folder with that ID in this workspace. |
| 422 | tags.too_many | More than 20 tags on one link. |
| 422 | tags.too_long | A tag is longer than 50 characters. |
Any request can also return the general errors: 401 for a missing or wrong key, 403 for a missing scope or plan feature, 404 for an unknown workspace and 429 when you're sending too fast.
Check a back-half
GET /aliases/availability
Says whether a back-half is free on a domain, without reserving it.
- Full path
/api/v1/workspaces/{workspaceId}/aliases/availability- API key scope
-
links:write - Role needed
- Manager or above
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
workspaceId |
string | Yes | Your workspace ID, from Settings → Workspace. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
alias |
string | Yes | The back-half to check. |
domainId |
string | No | The custom domain to check on. Leave out for the default short domain. |
Example request
curl
curl "https://bgum.me/api/v1/workspaces/$WORKSPACE_ID/aliases/availability?alias=summer-sale" \
-H "Authorization: Bearer $BGL_API_KEY"
JavaScript
const res = await fetch(`https://bgum.me/api/v1/workspaces/${process.env.WORKSPACE_ID}/aliases/availability?alias=summer-sale`, {
headers: {
Authorization: `Bearer ${process.env.BGL_API_KEY}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);
Python
import os
import requests
res = requests.get(
f"https://bgum.me/api/v1/workspaces/{os.environ['WORKSPACE_ID']}/aliases/availability?alias=summer-sale",
headers={
"Authorization": f"Bearer {os.environ['BGL_API_KEY']}",
},
timeout=30,
)
res.raise_for_status()
print(res.json())
Response
200 OK · application/json
JSON
{
"alias": "summer-sale",
"available": true
}
Good to know
- When the back-half isn't allowed, available is false and reason and code explain why, e.g. {"alias":"admin","available":false,"reason":"That back-half is reserved. Try another.","code":"alias.reserved"}. Someone else can still take a free back-half before you create the link.
Errors
Any request can also return the general errors: 401 for a missing or wrong key, 403 for a missing scope or plan feature, 404 for an unknown workspace and 429 when you're sending too fast.
Something missing or unclear? Tell us and we'll improve this page.