ExaLink Webhooks
Stream smart-link engagement to your backend in real time. Subscribe to click and conversion events, verify signatures, and keep dashboards in sync without polling the analytics API.
Sub-second delivery
Click events are pushed to your endpoint typically within 500ms of the redirect.
HMAC-SHA256
Every payload is signed with your webhook secret via X-Sendexa-Signature.
Automatic retries
Failed deliveries retry up to 5 times with exponential backoff over ~24 hours.
Two core events
exalink.click for every successful visit, exalink.converted for attributed goals.
<500ms
event → HTTP POST
5
exponential backoff
2
click + converted
SHA256
HMAC verification
Configure in the dashboard
exalink.click and/or exalink.converted. Endpoints must respond with 2xx within 10 seconds. Platform webhook concepts →Always verify signatures
X-Sendexa-Signature. Unverified endpoints are a spoofing risk.exalink.clickFired after a successful redirect (password passed if required).
exalink.convertedFired when a conversion is attributed to a link (API report or pixel).
Delivery envelope
Sendexa sends POST with Content-Type: application/json. Common headers:
| Field | Type | Required | Description |
|---|---|---|---|
| X-Sendexa-Signature | header | required | hex(HMAC-SHA256(raw_body, webhook_secret)). Compare in constant time. |
| X-Sendexa-Event | header | required | Event name, e.g. exalink.click or exalink.converted. |
| X-Sendexa-Delivery-Id | header | required | Unique delivery id. Use for idempotency — retries reuse the same id. |
| X-Sendexa-Timestamp | header | required | Unix seconds when the delivery was prepared. Reject if skew > 5 minutes. |
| User-Agent | header | optional | Sendexa-Webhooks/1.0 |
{"id": "evt_01HZY…","type": "exalink.click","createdAt": "2026-07-12T11:20:00.120Z","apiVersion": "2026-07-01","data": { }}
Emitted once per successful visit after access checks (expiry, max clicks, password). Failed password attempts do not emit this event.
Payload
{"id": "evt_01HZY4M8N2K0PQR7S9T1UVW3XY","type": "exalink.click","createdAt": "2026-07-12T11:20:00.120Z","apiVersion": "2026-07-01","data": {"clickId": "clk_01HZY4M8P5A2B3C4D5E6F7G8H9","link": {"id": "link_01HZXK9P2M7QVR8N4W3T6Y5B0A","shortUrl": "https://go.example.com/summer26","slug": "summer26","domain": "go.example.com","title": "Summer Sale 2026","destination": "https://shop.example.com/summer-sale?utm_source=sms&utm_medium=campaign&utm_campaign=summer_sale_2026"},"utm": {"source": "sms","medium": "campaign","campaign": "summer_sale_2026","term": "discount","content": "hero_cta"},"visitor": {"country": "GH","countryName": "Ghana","city": "Accra","device": "mobile","os": "Android","browser": "Chrome","isBot": false,"referrer": "whatsapp","referrerUrl": null},"request": {"ipHash": "a3f9c1…","userAgent": "Mozilla/5.0 (Linux; Android 14; …)","language": "en-GH"},"clickedAt": "2026-07-12T11:19:59.980Z","redirectStatus": 302}}
Key fields
| Field | Type | Required | Description |
|---|---|---|---|
| data.clickId | string | required | Stable id for this click. Safe to store as a primary key. |
| data.link.id | string | required | ExaLink id used with REST analytics endpoints. |
| data.link.destination | string | optional | Final redirect URL including merged UTM query parameters. |
| data.visitor.country | string | optional | ISO country code or XX if unknown. |
| data.visitor.device | string | optional | mobile | desktop | tablet | bot | other |
| data.visitor.referrer | string | optional | Normalized channel label (whatsapp, sms, direct, …). |
| data.request.ipHash | string | optional | One-way hash of IP for uniqueness — raw IP is never sent. |
| data.clickedAt | string (ISO 8601) | optional | When the edge recorded the click (UTC). |
Fired when a conversion is attributed to a smart link — either via the conversion reporting API / pixel, or when your integration maps a checkout / signup back to a prior clickId.
{"id": "evt_01HZY5N9Q3L1RST8U0V2WXY4ZA","type": "exalink.converted","createdAt": "2026-07-12T11:45:12.440Z","apiVersion": "2026-07-01","data": {"conversionId": "cnv_01HZY5N9R4B3C4D5E6F7G8H9I0","clickId": "clk_01HZY4M8P5A2B3C4D5E6F7G8H9","link": {"id": "link_01HZXK9P2M7QVR8N4W3T6Y5B0A","shortUrl": "https://go.example.com/summer26","slug": "summer26","title": "Summer Sale 2026"},"conversion": {"type": "purchase","value": 249.99,"currency": "GHS","orderId": "ord_99821","metadata": {"sku": "TEE-SUMMER-M","channel": "web"}},"utm": {"source": "sms","medium": "campaign","campaign": "summer_sale_2026","term": "discount","content": "hero_cta"},"convertedAt": "2026-07-12T11:45:12.200Z","attributionWindowHours": 168}}
| Field | Type | Required | Description |
|---|---|---|---|
| data.conversionId | string | required | Unique conversion id for idempotent processing. |
| data.clickId | string | optional | Original click that attributed this conversion, when known. |
| data.conversion.type | string | optional | Business label: purchase, signup, lead, custom, … |
| data.conversion.value | number | optional | Optional monetary value of the conversion. |
| data.conversion.currency | string | optional | ISO 4217 currency code when value is set. |
| data.attributionWindowHours | integer | optional | Lookback window used for attribution (default 168 = 7 days). |
Compute HMAC-SHA256 over the raw request body using your webhook secret. Compare to X-Sendexa-Signature with a constant-time equality check.
import crypto from 'crypto';import express from 'express';const app = express();// IMPORTANT: use raw body for signature verificationapp.post('/webhooks/sendexa',express.raw({ type: 'application/json' }),(req, res) => {const secret = process.env.SENDEXA_WEBHOOK_SECRET;const signature = req.get('X-Sendexa-Signature') || '';const timestamp = req.get('X-Sendexa-Timestamp') || '0';const deliveryId = req.get('X-Sendexa-Delivery-Id');const eventType = req.get('X-Sendexa-Event');// Optional: reject stale deliveriesconst skew = Math.abs(Date.now() / 1000 - Number(timestamp));if (skew > 300) {return res.status(401).send('stale timestamp');}const expected = crypto.createHmac('sha256', secret).update(req.body) // Buffer.digest('hex');const valid =signature.length === expected.length &&crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));if (!valid) {return res.status(401).send('invalid signature');}const payload = JSON.parse(req.body.toString('utf8'));// Idempotency: skip if deliveryId already processed// await db.webhookDeliveries.insertIgnore(deliveryId);switch (eventType || payload.type) {case 'exalink.click':// await onClick(payload.data);break;case 'exalink.converted':// await onConverted(payload.data);break;default:break;}res.status(200).json({ received: true });});
| Attempt | Delay after failure |
|---|---|
| 1 (initial) | — immediate |
| 2 | ~1 minute |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
| 5 | ~2 hours |
| 6 (final) | ~12 hours |
- Respond with
2xxwithin 10 seconds or the delivery is treated as failed - Same
X-Sendexa-Delivery-Idis reused on retries — design handlers to be idempotent - Non-2xx, TCP errors, and TLS failures all schedule the next attempt
- Inspect delivery history under Dashboard → Developers → Webhooks → Logs
async function onClick(data) {const { link, visitor, clickId } = data;await db.clicks.insert({id: clickId,linkId: link.id,country: visitor.country,device: visitor.device,referrer: visitor.referrer,at: data.clickedAt}).onConflict('id').ignore();await redis.hincrby(`exalink:${link.id}:counters`, 'clicks', 1);await redis.hincrby(`exalink:${link.id}:country`,visitor.country || 'XX',1);}async function onConverted(data) {await db.conversions.insert({id: data.conversionId,linkId: data.link.id,clickId: data.clickId,type: data.conversion.type,value: data.conversion.value,currency: data.conversion.currency,at: data.convertedAt}).onConflict('id').ignore();await redis.hincrby(`exalink:${data.link.id}:counters`, 'conversions', 1);}
- HTTPS endpoint with valid certificate
- Raw body used for HMAC (not re-serialized JSON)
- timingSafeEqual / hash_equals for signature compare
- Timestamp skew check (±5 minutes)
- Idempotent storage keyed by deliveryId or clickId / conversionId
- Return 2xx quickly; process heavy work async
- Subscribe only to events you need
- Alert if webhook failure rate spikes in dashboard logs
Beta note
Related
- Analytics API — reconcile webhook counters with historical queries
- Platform webhooks overview
- Webhook logs
- Security