Urlicer API v1

Webhooks

What we send when something happens in a workspace, how to check it came from us, and what happens when your endpoint does not answer.

A webhook sends an HTTPS POST to a URL of yours when something happens in a workspace: a link is created, edited or deleted, a custom domain is verified, a member joins. Use it to post new links to a chat, keep a spreadsheet or a CRM in step, or start your own processing without polling the API.

Endpoints are added in the app, under Webhooks on the workspace's Developers page, by the owner or an admin. Each endpoint picks the events it wants and gets its own signing secret. The help article walks through it.

Plans

Endpoints per workspace, read live from our plan catalog:

Plan Webhook endpoints
Free None
Pro None
Team 10
Business 25

After a downgrade, endpoints stay listed and can be edited or removed, but nothing is sent to them until the plan includes webhooks again.

The request

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Urlicer-Webhooks/1.0 (+https://urlicer.com/help/webhooks)
Urlicer-Event: link.created
Urlicer-Delivery: evt_5f0c2a9e41b7d3c8a61e9f02
Urlicer-Signature: t=1790769600,v1=3b1f0c…
  • Urlicer-Event: the event type, the same as type in the body.
  • Urlicer-Delivery: the event id, the same as id in the body. It stays the same on every retry and every resend, so use it to ignore an event you have already handled.
  • Urlicer-Signature: see verifying a request.

Every body has the same envelope, with the event in data:

{
  "id": "evt_5f0c2a9e41b7d3c8a61e9f02",
  "type": "link.created",
  "createdAt": "2026-09-30T12:00:00.000Z",
  "workspace": { "id": "8f3a1c2b9d4e5f60", "name": "Acme Marketing" },
  "data": {
    "link": {
      "id": 90412,
      "short": "https://go.acme.com/spring",
      "code": "spring",
      "domain": "go.acme.com",
      "url": "https://acme.com/sale?utm_source=flyer",
      "active": true,
      "expiresOn": null,
      "maxClicks": null,
      "startsOn": null,
      "fallbackUrl": null,
      "createdOn": 1790769600,
      "folder": "Campaigns/Spring",
      "title": "Spring flyer",
      "notes": null
    }
  }
}

workspace.id is the key in the workspace's app URLs. createdAt is when the event happened, not when this attempt was made.

Events

A link was created in the app or through the API. link has the fields the API returns for a link, without clicks, plus id, our number for the link. Times are unix seconds.

{
    "link": {
      "id": 90412,
      "short": "https://go.acme.com/spring",
      "code": "spring",
      "domain": "go.acme.com",
      "url": "https://acme.com/sale?utm_source=flyer",
      "active": true,
      "expiresOn": null,
      "maxClicks": null,
      "startsOn": null,
      "fallbackUrl": null,
      "createdOn": 1790769600,
      "folder": "Campaigns/Spring",
      "title": "Spring flyer",
      "notes": null
    }
}

A link was edited. link is its state after the edit, and changed names the fields of link that differ from before. It can be empty, when the edit only touched settings that are not in the link object, such as targeting rules.

{
    "link": {
      "id": 90412,
      "short": "https://go.acme.com/spring",
      "code": "spring",
      "domain": "go.acme.com",
      "url": "https://acme.com/sale?utm_source=flyer",
      "active": false,
      "expiresOn": null,
      "maxClicks": null,
      "startsOn": null,
      "fallbackUrl": null,
      "createdOn": 1790769600,
      "folder": "Campaigns/Spring",
      "title": "Spring flyer",
      "notes": null
    },
  "changed": ["active", "url"]
}

link is the link as it was just before it was deleted.

A bulk create, in the app or through the bulk endpoint. The links come in events of up to 100, each with batch saying which part of the whole it is, so a 250-link import is three events. A bulk create does not also send one link.created per link.

{
  "links": [ { "id": 90413, "short": "https://ivi.to/a8Kq2", … }, … ],
  "batch": { "index": 1, "count": 3 }
}

domain.verified

A custom domain passed its DNS ownership check.

{
  "domain": { "id": 311, "host": "go.acme.com" }
}

member.joined

Someone became a member: via is invitation when they accepted one, added when an admin added an existing account.

{
  "member": { "id": 5120, "email": "sam@acme.com", "role": "Member", "via": "invitation" }
}

webhook.test

Sent only by the Test button, to that one endpoint, whatever events it takes. data holds a message. A test is tried once and never retried.

More event types will be added. An endpoint set to all events receives new ones as they arrive, so ignore types you do not handle rather than rejecting them.

Verifying a request

Urlicer-Signature looks like t=1790769600,v1=3b1f0c…. t is the unix time the attempt was sent. v1 is the hex HMAC-SHA256 of t, a dot and the raw request body, keyed with the endpoint's secret. Compute it yourself and compare in constant time. Check that t is recent, five minutes is a good limit, so a captured request cannot be replayed later. Sign the body exactly as received: parsing the JSON and encoding it again changes the bytes.

# $SIGNATURE is the Urlicer-Signature header, body.json the raw request body.
t=$(printf '%s' "$SIGNATURE" | sed -E 's/^t=([0-9]+),v1=.*/\1/')
v1=$(printf '%s' "$SIGNATURE" | sed -E 's/.*,v1=([0-9a-f]+)$/\1/')
expected=$( { printf '%s.' "$t"; cat body.json; } \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //')
[ "$expected" = "$v1" ] && echo valid || echo invalid

The secret is shown in the app, where it can also be replaced. A replacement takes effect from the next attempt, retries included, so update your receiver as you replace it.

Responding

  • Any 2xx within 10 seconds counts as delivered. Anything else is a failed attempt: another status, a timeout, a refused connection or a TLS error.
  • Redirects are not followed, so a 3xx is a failure too. Give us the final URL.
  • We read at most the first 1 KB of your answer, and keep it in the delivery log.
  • Answer first and process afterwards. A slow receiver risks the 10-second limit, and a timed-out attempt is sent again even if you did handle it.

Retries

A failed attempt is tried again after 1 minute, then 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours: seven attempts over about 21 hours. After the seventh it is marked failed, and can still be resent by hand from the delivery log.

Events are sent in parallel and retried on their own schedules, so they can arrive out of order. When order matters, compare createdAt.

An endpoint is switched off when 20 events in a row have failed every attempt, or when it is still failing and nothing has been delivered for three days. The workspace owner gets an email. While it is off, nothing is sent to it and events that happen are not kept for later. Turning it back on in the settings starts the count again.

Endpoint requirements

  • HTTPS on port 443, with a valid certificate.
  • A public address: a host that resolves to a private or reserved network address is refused when you save it, and checked again before every attempt.
  • No user name or password in the URL. Put a token in the path or query if you want one on top of the signature.

The delivery log in the settings keeps every attempt for 30 days.