Outbound webhooks
Have ShortZen call your endpoint when a link is created or clicked, and verify the calls are genuinely from us.
Reviewed 24 Oct 2026Available from the Starter plan
Push instead of poll
Rather than asking the API every few minutes whether anything happened, register an endpoint and ShortZen calls you when it does.
| Event | Fires when |
|---|---|
| link.created | A new link is created, from the dashboard or the API. |
| link.clicked | A click is recorded. Automated traffic does not fire this. |
| conversion.recorded | A conversion pixel or API event is stored. Carries the goal, the credited link and, for an API event, its value and currency. Duplicates of the same idempotency key do not fire this. |
| conversion.deleted | An owner or admin deletes a conversion from the events list on Analytics. Carries the same id, so you can remove your copy. |
- Webhook endpoints, by plan
- Free Not included · Starter 1 · Pro 5 · Business 20
- Webhook deliveries / month, by plan
- Free Not included · Starter 2,000 · Pro 20,000 · Business 100,000
Registering an endpoint
On the Developer page, give a URL and pick which events it should receive. The URL must be https: plain http is refused, because a webhook body is your data crossing the internet. The URL and signing secret cannot be changed later; the events can, from the endpoint sheet.
Each endpoint gets a signing secret, shown once. Copy it into your secret store and acknowledge that you have saved it before leaving the page: we cannot show it again.
Verifying a delivery is really from us
Your endpoint is a public URL
Each delivery carries headers naming the event, a signature, and an id for this delivery:
x-shortzen-event: link.created
x-shortzen-signature: t=1700000000,v1=<hex hmac-sha256>
x-shortzen-delivery-id: <delivery id>The body is a JSON envelope: the event name, the timestamp the delivery was signed at, and the event's own fields under data.
{
"event": "link.created",
"createdAt": 1700000000,
"data": {
"id": "lnk_1a2b3c4d5e6f7a8b",
"slug": "spring-sale",
"destinationUrl": "https://example.com/spring-sale"
}
}createdAt is the same value as the t in the signature header, in seconds. It is stamped per attempt rather than when the event happened, so a retry of one delivery arrives with a later createdAt than the attempt before it.
The signature is an HMAC-SHA256 over the timestamp, a full stop, and the raw request body, keyed with the endpoint's signing secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
const parts = header.split(',').map(p => p.split('='));
const timestamp = parts.find(([key]) => key === 't')?.[1];
// One v1 per signing secret still accepted: two while a rotation overlaps, so any match passes.
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Constant-time compare: a plain === leaks the answer one byte at a time.
const a = Buffer.from(expected);
return signatures.some((signature) => {
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
});
}While you rotate a signing secret, the header carries one v1 for each secret that is still accepted: t=…,v1=<new secret>,v1=<old secret>. Accept the delivery if any of them matches, as the function above does. A verifier that reads only the first v1, or only the last, rejects deliveries until you switch the secret.
- Sign over the raw body exactly as received. Parsing to JSON and re-serialising changes the bytes and the signature will not match.
- Compare in constant time.
- Reject deliveries whose timestamp is far from now, so an old captured request cannot be replayed at you.
Rotating the signing secret
Open an endpoint on the Developer page and choose Rotate secret. ShortZen starts signing with a new secret on the same endpoint, so its history, URL and events stay as they are. The new secret is shown once, like the first one: copy it into your secret store before you leave.
You choose what happens to the current secret. Keep it working for 24 hours is the default: during that day every delivery carries a signature for both secrets, so you can deploy the new one without dropping events. Stop it now is for a secret that may have leaked: deliveries are signed with the new secret only, and your receiver rejects them until you update it.
Update your verifier first, then rotate
Retries and delivery history
Answer with a 2xx quickly. A non-2xx or a timeout is treated as a failure and retried automatically.
Acknowledge first, work afterwards
Open an endpoint to see its delivery history: one row per event, showing the attempts and how it finished. Retry is offered on failed deliveries only, without needing the original event to happen again. Pending deliveries are still in flight; successful ones are already done. History is kept for 90 days: it is useful for chasing down a recent "my webhook did not fire" report, not a permanent audit trail.
Because retries exist, your handler must tolerate seeing the same event twice. Record the x-shortzen-delivery-id header and ignore a delivery id you have already processed. It stays the same across every automatic retry of one delivery, while the signature and createdAt do not. A manual retry is a new delivery with a new id, so if you need to collapse those too, key on the fields in data as well: id for link.created, linkId with occurredAt for link.clicked, id for conversion.recorded.
When an endpoint gets auto-disabled
If an endpoint answers with a bad HTTP response (a non-2xx status, or a response your handler rejects) for 10 deliveries in a row, ShortZen marks it Needs attention and posts an in-app notification. A network-level problem on our side reaching your endpoint (a DNS failure, a timed-out connection) does not count toward that streak, so the endpoint is only auto-disabled for something genuinely wrong with it.
Paused is not the same as Needs attention
Step by step
Set up an endpoint and prove it works end to end
For: Developers, on Starter or above
Before you start
- A plan that includes webhooks.
- An https endpoint that is publicly reachable.
- Somewhere to store the signing secret.
Steps
Build the endpoint so it verifies the signature and returns 2xx immediately.
Write it before you register it. An endpoint that 500s on its first delivery starts you off inside the retry logic.
Deploy it and confirm it is reachable over https from outside your network.
On the Developer page, add the endpoint URL and select link.created.
The endpoint is created with a signing secret.
Copy the secret into your application's configuration and redeploy.
Open the endpoint and click Send test. It is also in the row's menu.
This proves the URL is reachable and the signature verifies, before any real event depends on them. It counts against your monthly delivery budget like any other delivery, and works on a paused endpoint too.
A test delivery is dispatched and the delivery history opens showing it.
Create a test link in the dashboard.
A delivery is dispatched to your endpoint.
Check your logs: the signature verified and you returned 2xx.
Open the endpoint's delivery history in ShortZen and confirm it shows as delivered.
Both sides agreeing is the actual test. Either one alone can be misleading.
Deliberately make your endpoint fail once, create another link, and watch the retry.
You want to have seen the failure path before it happens for real at 3am.
Add link.clicked once the created path is solid.
What you should see
A verified endpoint receiving live events, with retry behaviour you have seen work and a delivery history you can audit.
How to check it worked
- Deliveries appear as successful in the endpoint's history.
- Your endpoint rejects a request with a tampered signature.
- A deliberately failed delivery is retried, and Retry re-delivers it successfully.
Edge cases
- Your endpoint is behind a firewall or on localhost.
- It cannot be reached. Use a tunnelling tool while developing, and a public https URL in production.
- You are at your plan's endpoint limit.
- Delete an endpoint you no longer use, or upgrade.
- You need an event we do not emit.
- Poll the REST API for it instead. link.created, link.clicked, and conversion.recorded are the events dispatched today.
If something goes wrong
- Signature verification fails every time.
- In almost every case, it is the body. Sign the raw bytes as received, before any JSON parsing or framework middleware rewrites them.
- Every delivery is rejected right after you rotated the secret.
- Your receiver still holds the old secret. Put the new one in place. If you kept the old secret for the 24 hours, make sure the verifier accepts any signature in the header; one that reads a single v1 value can fail while both are sent. If you left the page before copying the new secret, rotate again: the secret your receiver holds stops being accepted at that point, so update it with the next one.
- You are receiving duplicate events.
- Expected: retries are at-least-once. Make your handler idempotent by recording which events you have already processed.
- The endpoint is marked Needs attention.
- URLs cannot be edited. Add a replacement, switch the consumer, then delete the failing one. Re-enable only if you already fixed the receiver.
- Deliveries failed while your service was down.
- Fix the service, then use Retry on the failed rows in the delivery history. You do not need to recreate the original events.