ExaLink API
Beta
Webhook

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.

Latency

<500ms

event → HTTP POST

Max retries

5

exponential backoff

Events

2

click + converted

Signature

SHA256

HMAC verification

Event types
exalink.click

Fired after a successful redirect (password passed if required).

exalink.converted

Fired when a conversion is attributed to a link (API report or pixel).

Delivery envelope

Sendexa sends POST with Content-Type: application/json. Common headers:

FieldTypeRequiredDescription
X-Sendexa-Signatureheader
required
hex(HMAC-SHA256(raw_body, webhook_secret)). Compare in constant time.
X-Sendexa-Eventheader
required
Event name, e.g. exalink.click or exalink.converted.
X-Sendexa-Delivery-Idheader
required
Unique delivery id. Use for idempotency — retries reuse the same id.
X-Sendexa-Timestampheader
required
Unix seconds when the delivery was prepared. Reject if skew > 5 minutes.
User-AgentheaderoptionalSendexa-Webhooks/1.0
JSON
{
"id": "evt_01HZY…",
"type": "exalink.click",
"createdAt": "2026-07-12T11:20:00.120Z",
"apiVersion": "2026-07-01",
"data": { }
}
Verify signatures

Compute HMAC-SHA256 over the raw request body using your webhook secret. Compare to X-Sendexa-Signature with a constant-time equality check.

JavaScript
import crypto from 'crypto';
import express from 'express';
const app = express();
// IMPORTANT: use raw body for signature verification
app.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 deliveries
const 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 });
}
);
Retries & timeouts
AttemptDelay after failure
1 (initial)— immediate
2~1 minute
3~5 minutes
4~30 minutes
5~2 hours
6 (final)~12 hours
  • Respond with 2xx within 10 seconds or the delivery is treated as failed
  • Same X-Sendexa-Delivery-Id is 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
Example: update a live counter
JavaScript
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);
}
Production checklist
  • 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