9 min readFeatures

Webhooks for Link Events: Payloads, Signatures, Retries

URL shortener webhooks for link events: the real payload envelope, X-Webhook-Signature HMAC checks in Node and Python, the retry policy and dedup keys.

Marius Voß
DevRel · edge infra
Pixel-style diagram of URL shortener webhooks: link.created, link.updated and member.invited events pass through signed delivery to event, siem and discord endpoints, under a bar reading HMAC-SHA256 v1=, 10s timeout, 3 attempts

Elido's URL shortener webhooks POST a signed JSON envelope to your HTTPS endpoint whenever something changes in a workspace: a link is created, edited, deleted, expires or hits its click cap, a member is invited, a domain verifies. Each request carries an X-Webhook-Signature: v1=<hex> header, which is HMAC-SHA256 over {timestamp}.{raw_body}, and a failed delivery gets three attempts in about twenty minutes.

What it doesn't send today is a link click webhook. Clicks leave through the analytics API and the event forwarders instead, and I'll show where at the end. This post is the outbound half of the API surface; the URL shortener API + SDKs quickstart covers the inbound half, and smart links explained is the features cornerstone the link events come from.

Every event below reaches a webhook endpoint that subscribed to it by name. The dashboard's new-endpoint form offers checkboxes for the eight most common ones; the API accepts any name from the list.

EventFires whenDashboard checkbox
link.createdA link is created, one by one or in a bulk importyes
link.updatedDestination, settings or status change, bulk edits, restoresyes
link.deletedA link is deleted, singly or in bulkyes
link.expiredA link passes its expiry dateAPI only
link.cap_reachedA link reaches its maximum click countAPI only
link.brokenThe broken-link check sees the destination failingAPI only
workspace.created, workspace.updatedA workspace is provisioned or its settings changeyes
member.invited, member.removedA member is added (directly, by SCIM or an accepted invite) or removedyes
member.role_changedA member's role changesAPI only
invitation.created, invitation.acceptedAn invite is sent or acceptedAPI only
domain.verified, domain.ssl_failedA custom domain passes DNS checks, or still fails them after 24 hoursAPI only
audit.eventAny audit-log entryyes

Encrypted links add two more, link.encrypted_created and link.encryption_rotated. If you'd rather not keep the list current by hand, create a siem endpoint: it gets every event in the workspace, audit entries included, with no subscription filter at all.

On the roadmap, not live yet: click.created (a sampled click stream), billing events such as billing.subscription_upgraded, and per-endpoint filters like "only links in folder X". Don't subscribe to those names today; nothing publishes them.

Creating a Webhook Endpoint With the API

An endpoint belongs to one workspace. You create it with a POST to /v1/workspaces/{workspace_id}/webhooks:

curl -X POST "https://api.elido.app/v1/workspaces/$WORKSPACE_ID/webhooks" \
  -H "Authorization: Bearer elido_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/elido",
    "events": ["link.created", "link.updated", "link.deleted"],
    "description": "CRM sync",
    "kind": "event"
  }'

The response is 201 with the endpoint and, for event and siem kinds, a one-time secret:

{
  "endpoint": {
    "id": 7,
    "workspace_id": 42,
    "url": "https://hooks.example.com/elido",
    "events": ["link.created", "link.updated", "link.deleted"],
    "is_active": true,
    "description": "CRM sync",
    "kind": "event",
    "config": {},
    "created_at": "2026-09-21T09:12:44Z"
  },
  "secret": "whsec_9f2c..."
}

Elido generates the secret; you don't send one. Copy it now, because no later call returns it. kind takes five values. event and siem are the signed JSON deliveries this post covers. discord, telegram and sentry reshape the same events into a chat message or a Sentry event, authenticate through the URL or an encrypted bot token, and carry no HMAC headers.

Permissions changed this month. Reading endpoints and the delivery log needs workspace.view. Creating, editing, deleting, rotating a secret or re-sending a delivery needs workspace.edit, which means an admin or owner. An API key works inside the workspace it was issued for and never above the role picked when it was made, so a viewer-level key gets a 403 on the POST above. Full reference: the webhooks docs.

The Webhook Payload Envelope

Every signed delivery has the same four-field envelope. data holds the record that changed, so for link events it's the link row:

{
  "type": "link.created",
  "workspace_id": 42,
  "data": {
    "id": 91834,
    "workspace_id": 42,
    "domain_id": 3,
    "slug": "spring-sale",
    "destination_url": "https://shop.example.com/spring",
    "title": "Spring sale landing",
    "tags": ["newsletter"],
    "status": "active",
    "expires_at": null,
    "max_clicks": null,
    "redirect_status": 302,
    "created_by_user_id": 17,
    "created_at": "2026-09-21T09:14:02.184311Z"
  },
  "timestamp": "2026-09-21T09:14:02Z"
}

That sample is trimmed; the real data carries every column of the link, including targeting rules, folder, campaign and scan fields. There's no event ID and no short_url in the body, so build the short URL from your domain and slug if you need it. Scheduled events send a smaller object instead: link.expired has link_id, slug and destination_url, and link.cap_reached adds cap and clicks.

One fix you should know about if you logged payloads before this week: secret fields are now stripped before a payload leaves Elido. A password-protected link's password_hash and an invitation's token used to appear in data; they no longer do, in new deliveries or in the delivery log. If you stored old payloads, that data is worth purging.

Verifying the X-Webhook-Signature Header

Each signed request carries these headers:

X-Webhook-Signature: v1=5d8f0c3e...
X-Elido-Signature: v1=5d8f0c3e...
X-Webhook-Timestamp: 1790068442
X-Webhook-Event: link.created
X-Webhook-Delivery: 55120
User-Agent: Elido-Webhooks/1.0

The two signature headers hold the same value. Elido computes HMAC-SHA256, as defined in RFC 2104, with the whole whsec_... string as the key, over the timestamp, a dot, and the raw body bytes. The hex digest gets a v1= prefix. There's no t= field inside the header; the timestamp lives in its own header.

Signature verification flow: the X-Webhook-Signature and X-Webhook-Timestamp headers plus the raw body and secret feed an HMAC-SHA256 over ts dot body, compared in constant time as v1= hex, then a receiver-side 5-minute freshness gate splits into process the event or reject with 400

In Node, sign the raw bytes, not a re-serialised object. Express needs express.raw({ type: "application/json" }) on this route for that reason:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyElido(secret, headers, rawBody, toleranceSec = 300) {
  const ts = headers["x-webhook-timestamp"] ?? "";
  if (!/^\d+$/.test(ts)) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;

  const mac = createHmac("sha256", secret).update(`${ts}.`).update(rawBody);
  const expected = Buffer.from("v1=" + mac.digest("hex"));

  // During a rotation the old secret signs X-Elido-Signature-Previous.
  return ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
    const got = Buffer.from(headers[name] ?? "");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

The length check matters: Node's timingSafeEqual throws on buffers of different sizes rather than returning false. The Python version with the standard hmac module:

import hashlib
import hmac
import time


def verify_elido(secret: str, headers, raw_body: bytes, tolerance: int = 300) -> bool:
    ts = headers.get("X-Webhook-Timestamp", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
        return False
    digest = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256)
    expected = "v1=" + digest.hexdigest()
    for name in ("X-Elido-Signature", "X-Elido-Signature-Previous"):
        got = headers.get(name)
        if got and hmac.compare_digest(got, expected):
            return True
    return False

If the check keeps failing, the webhook signature verification guide has a Go version, an n8n Code node and the usual causes of a mismatch. The five-minute window is your check, not ours. Elido stamps a fresh timestamp on every attempt, retries included, so a legitimate retry never looks stale. Without the window, anyone who captured one request could replay it next week and the signature would still match.

Rotation is POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, or the Rotate button on the endpoint page. You get the new secret once. For the next seven days each delivery also carries X-Elido-Signature-Previous, signed with the old secret, which is why both functions above try it. Deploy the new secret any time in that week and nothing fails.

The Webhook Retry Policy

The delivery worker picks up pending deliveries every few seconds, so a link change usually reaches you within seconds. Any 2xx response marks the delivery done. A non-2xx status, a network error or no response within 10 seconds counts as a failed attempt.

Webhook retry schedule bar chart: attempt 1 at T+0, attempt 2 five minutes after a failure at T+5m, attempt 3 fifteen minutes later at T+20m, then the delivery is marked failed with no more automatic tries until someone presses Retry or calls the retry endpoint

Three attempts per delivery, twenty minutes end to end. That's short on purpose, and honestly it's shorter than I'd pick for a receiver behind a flaky VPN. A two-hour outage on your side won't be covered by automatic retries. What covers it is the delivery log: GET /v1/workspaces/{workspace_id}/webhooks/{id}/deliveries lists each delivery with status, HTTP code, latency, attempt count and next retry time, and the endpoint page shows the same rows with a Retry button. Retry, or POST .../deliveries/{delivery_id}/retry, re-arms a failed or delivered delivery with a fresh three-attempt budget and returns 202. A delivery still pending gets 409.

Some failures skip the retries. A Telegram endpoint missing its chat_id, or a malformed Sentry DSN, is marked failed at once, because repeating the request won't fix configuration. To take an endpoint offline without deleting it, send PUT with "is_active": false; paused endpoints get no new deliveries.

If your handler does heavy work, return 200 first and queue the job. The ten-second cut-off is where a slow-but-successful handler turns into a duplicate, which brings us to deduplication.

Planning a receiver for your team? The webhooks feature page shows the dashboard side of all this.

Webhook Idempotency and Ordering

Elido delivers at least once. The retry section showed one way a duplicate happens: your handler commits the work, then misses the ten-second window, and Elido sends it again. A manual Retry re-sends on purpose.

Both cases keep the same X-Webhook-Delivery value, because it's the ID of one delivery to one endpoint, not of one attempt. Key on it:

CREATE TABLE elido_webhook_seen (
  delivery_id BIGINT PRIMARY KEY,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- in the handler, inside the same transaction as your work:
INSERT INTO elido_webhook_seen (delivery_id) VALUES ($1)
ON CONFLICT (delivery_id) DO NOTHING
RETURNING delivery_id;
-- no row back means you've already handled this delivery

Two endpoints subscribed to the same event get two different delivery IDs, so dedupe per endpoint. In rare restarts on our side an event can be queued twice as separate deliveries; if a double write would hurt, add a second guard on type plus data.id plus data.updated_at.

There's no ordering promise. Deliveries go out oldest-due first, but a retry of an early event can land after a later one. Compare data.updated_at against what you've stored before overwriting a link, and don't lean on the envelope timestamp for ordering: it only has one-second precision.

Where Click Data Lives Instead of a Click Webhook

This is the part the older version of this post got wrong. There's no click webhook today, and click.created is planned, not shipped. The redirect path is kept free of synchronous work, which the fire-and-forget click ingestion post explains, and clicks go to analytics storage rather than into the webhook queue.

For click-level data now, you've got two routes:

  1. Event forwarders. Each short-link click becomes a server-side event in the tool you already use: Mixpanel link click events, Klaviyo click events on profiles, or Datadog redirect metrics for ops dashboards.
  2. The analytics API. GET /v1/analytics/workspaces/{workspace_id}/clicks/recent returns recent clicks, and clicks.csv exports them, so a scheduled job can pull the rows it needs.

Which one fits depends on latency and where the data ends up; webhooks vs polling for click tracking walks through that trade-off. And if the sampled click.created stream ships, this page will say so first.

Read the cornerstone: smart links explained.

Frequently asked questions

Does Elido send a webhook for every link click?

Not today. Webhooks cover workspace changes such as link.created, link.updated, link.expired and member.invited. A click.created event is on the roadmap as a sampled stream. For click-level data now, use the analytics API or an event forwarder such as Mixpanel, Klaviyo or Datadog.

How do I verify an Elido webhook signature?

Compute HMAC-SHA256 over the X-Webhook-Timestamp value, a dot and the raw request body, keyed with your whsec_ secret. Hex-encode it, prefix v1= and compare it in constant time with the X-Webhook-Signature header. Reject timestamps more than five minutes old.

How many times does Elido retry a failed webhook?

Each delivery gets three attempts: one right away, one five minutes after a failure and one fifteen minutes after that. Any non-2xx status, network error or response slower than ten seconds counts as a failure. After the third, the delivery is marked failed until you press Retry.

What is the difference between X-Webhook-Signature and X-Elido-Signature?

Nothing but the name. Both headers carry the same v1= signature, and X-Webhook-Signature stays for older receivers. During a secret rotation Elido also sends X-Elido-Signature-Previous, signed with the old secret, for seven days.

How do I stop processing the same webhook twice?

Store the X-Webhook-Delivery header value and skip any request whose value you have already handled. It identifies one delivery to one endpoint and stays the same across automatic retries and manual re-sends, so a unique index on it is enough.

Who can create or delete webhooks in a workspace?

Workspace admins and owners. Listing endpoints and reading the delivery log needs view access; creating, editing, deleting, rotating a secret or re-sending a delivery needs the workspace.edit permission. API keys are capped at the role picked when the key was created.

Try Elido

Paste a URL, get a working short link

No signup. Link lives for 30 days. Sign up to keep it forever.

Free, no signup required · 2 per day

Try Elido

EU-hosted URL shortener with custom domains, deep analytics, and an open API. Free tier - no credit card.

Tags
url shortener webhooks
link click webhook
webhook signature verification
webhook retry policy
webhook idempotency
webhook payload

Continue reading