Webhooks for contacts and mailing
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.
- Set up an incoming webhook in Slack pointing at a channel.
- 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
- Register your function's URL with FastLinkIt:
POST /api/payment/webhooks
{ "url": "https://your-worker/fli-hook", "events": "contact.created" }
- Save the returned secret in the worker's environment.
- Done. New subscribers ping Slack within seconds.
Related
- Bulk APIs and subscribe widget — what fires
contact.created - API Keys — register a key with the
webhooksscope - Drip campaigns — same trigger taxonomy as the webhook events