FastLinkIt

Webhooks for contacts and mailing

API & Integrationscontactsapimailing4 min read

Available on Professional and Unlimited plans

FastLinkIt fires HMAC-signed POST requests to your endpoint when contact and mailing events happen. Use them to sync contacts to a CRM, route opens and clicks into Slack, or trigger any external workflow that reacts to email engagement.

Registering a webhook

Webhooks reuse the same WebhookRegistration infrastructure as payment webhooks — one registration list, one shared moderation surface.

POST /api/payment/webhooks
X-Api-Key: fli_...
Content-Type: application/json

{
  "url": "https://your-receiver.example.com/hook",
  "events": "contact.created,contact.unsubscribed,mailing.opened,mailing.clicked"
}

Response:

{
  "id": "...",
  "url": "https://your-receiver.example.com/hook",
  "events": "contact.created,contact.unsubscribed,mailing.opened,mailing.clicked",
  "secret": "sha256-of-something-random"
}

Save the secret — it's needed to verify HMAC signatures and is shown only once on creation.

Event catalog

Event Fires when… First-time only?
contact.created A contact is auto-upserted via IContactService.UpsertFromSourceAsync (booking, shop purchase, donation, ticket, inbox, contact form, etc.) ✅ Yes — first creation only
contact.updated A tag is applied via AddTagAsync ✅ Per new tag (re-tag is no-op)
contact.unsubscribed RFC-8058 unsubscribe endpoint hit n/a
contact.bounced A send-time SMTP rejection flips IsBounced true ✅ Only on first flip — not on every subsequent re-bounce
mailing.sent Mailing reaches a terminal state (sent / failed / ab_waiting) n/a — fires once per mailing
mailing.opened First tracking-pixel hit per recipient ✅ First open only
mailing.clicked First click-tracking redirect per recipient ✅ First click only

Payload shape

Every payload uses the same envelope:

{
  "event": "mailing.opened",
  "timestamp": "2026-05-14T14:32:18Z",
  "data": {
    "mailingId": "3f...",
    "recipientEmail": "alice@example.com",
    "openedAt": "2026-05-14T14:32:17Z",
    "country": "GB",
    "deviceType": "mobile"
  }
}

Field names are camelCase. The data object's contents are event-specific:

contact.created

{ "contactId": "...", "email": "...", "firstName": "...", "lastName": "...", "source": "booking", "sourceDetail": "abc123", "createdAt": "..." }

contact.updated

{ "contactId": "...", "change": "tag.added", "tag": "vip" }

contact.unsubscribed

{ "contactId": "...", "email": "...", "unsubscribedAt": "..." }

contact.bounced

{ "contactId": "...", "email": "...", "bouncedAt": "...", "reason": "550 5.1.1 No such mailbox" }

mailing.sent

{ "mailingId": "...", "subject": "...", "status": "sent", "totalRecipients": 500, "sentCount": 498, "failedCount": 1, "skippedCount": 1, "completedAt": "..." }

mailing.opened

{ "mailingId": "...", "recipientEmail": "...", "openedAt": "...", "country": "GB", "deviceType": "mobile" }

mailing.clicked

{ "mailingId": "...", "recipientEmail": "...", "clickedUrl": "https://...", "clickedAt": "..." }

Verifying signatures

Every POST has an X-FastLinkIt-Signature header:

X-FastLinkIt-Signature: sha256=hex-encoded-hmac

Verify with HMAC-SHA256 using the secret from registration as the key and the raw request body as the data:

import crypto from 'crypto';

function verify(secret, body, signatureHeader) {
  const expected = 'sha256=' +
    crypto.createHmac('sha256', secret).update(body).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signatureHeader)
  );
}

Always use a timing-safe comparison; never compare with == or equals.

Failure handling

Non-2xx responses or transport errors increment the registration's FailureCount. After 10 consecutive failures, the registration is auto-disabled (IsActive = false) and webhook delivery stops. A successful POST resets the counter.

This means a perpetually-broken receiver doesn't back up the queue. Re-enable manually at /payments/webhooks once your endpoint is fixed.

Worked example — sync new contacts to Slack

Goal: get a Slack message every time someone subscribes via the embedded subscribe widget.

  1. Set up an incoming webhook in Slack pointing at a channel.
  2. Build a tiny serverless function (Cloudflare Worker, AWS Lambda, etc.) that:
    • Reads the FastLinkIt POST
    • Verifies the HMAC signature
    • Forwards a formatted message to Slack
  3. Register your function's URL with FastLinkIt:
POST /api/payment/webhooks
{ "url": "https://your-worker/fli-hook", "events": "contact.created" }
  1. Save the returned secret in the worker's environment.
  2. Done. New subscribers ping Slack within seconds.

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.