Events
Webhooks
A signed POST to your server when a conversion arrives or changes status.
How webhooks work
Register an https URL and we POST a signed JSON event to it when a conversion on your account arrives or changes status, so you stop polling reports to find out.
- Each endpoint subscribes to the event types it wants. An event is delivered to every active endpoint subscribed to its type.
- Answer with any
2xxwithin ten seconds. Anything else (another status, a redirect, a timeout) counts as a failure and is retried. - An account can have up to five endpoints.
Acknowledge first, work later
200 as soon as the signature checks out, and do the slow part, like database writes and emails, in a background job. A handler that takes longer than ten seconds is a failed delivery even if it finishes.POST /linkapprove HTTP/1.1
Host: hooks.trailnotes.example
Content-Type: application/json
User-Agent: LinkApprove-Webhooks/1.0
LinkApprove-Event-Id: evt_120460
LinkApprove-Delivery-Attempt: 1
LinkApprove-Signature: t=1755676812,v1=5f2b1c0e9d7a6b4c3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d
{
"id": "evt_120460",
"type": "conversion.updated",
"createdAt": "2026-08-20T08:00:12Z",
"data": { "conversion": { "id": 771204, "status": "approved" } }
}Events and payloads
Every event has the same envelope: an id prefixed evt_, its type, when it was createdAt, and a data object.
conversion.created: the first time we see a conversion attributed to you.conversion.updated: its status changed. A network resending a conversion with the same status sends nothing.webhook.test: only from Send a test event. You cannot subscribe to it.
idintegerThe conversion's LinkApprove ID. Stable across every event for the same sale.
programIdinteger | nullThe program it was earned on.
channelIdinteger | nullThe channel that sent the click.
statusenumThe current status: pending, approved, available, ready_for_payout or rejected.
saleAmountstring | nullThe order value, as the network reported it.
commissionstring | nullYour share of the commission, as the network reported it.
currencystring | nullISO 4217 code for both amounts. Null when the network did not say.
occurredAtstring | nullWhen the sale happened, according to the network.
Duplicates and ordering
Deliveries can arrive more than once, after a retry or a replay, and are not guaranteed to arrive in order. Store each event id you have handled and skip repeats, and when two events describe the same conversion, keep the one with the later createdAt.
{
"id": "evt_120411",
"type": "conversion.created",
"createdAt": "2026-08-18T21:14:03Z",
"data": {
"conversion": {
"id": 771204,
"programId": 4821,
"channelId": 12,
"status": "pending",
"saleAmount": "129.00",
"commission": "7.22",
"currency": "USD",
"occurredAt": "2026-08-18T20:58:41Z"
}
}
}Verify the signature
Anyone can POST to your URL, so check every request before trusting it. The LinkApprove-Signature header has two parts: t, the Unix time we signed at, and v1, a hex HMAC-SHA256.
- Join
t, a literal.and the raw request body: the exact bytes, before any JSON parsing. - Compute HMAC-SHA256 of that string, keyed with the endpoint's whole secret including the
whsec_prefix. - Compare it to
v1in constant time, and reject the request iftis more than five minutes away from your clock.
Sign the raw body
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SECONDS = 300;
const app = express();
app.post(
"/linkapprove",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("LinkApprove-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=", 2))
);
const expected = crypto
.createHmac("sha256", process.env.LINKAPPROVE_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest("hex");
const isFresh =
Math.abs(Date.now() / 1000 - Number(parts.t)) < TOLERANCE_SECONDS;
const isValid =
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!isFresh || !isValid) return res.sendStatus(400);
const event = JSON.parse(req.body);
console.log(event.type, event.data);
res.sendStatus(200);
}
);
app.listen(3000);Retries and disabling
A failed delivery is retried up to eight attempts in total, backing off from one minute to a day. Times are approximate because deliveries go out in batches every minute. LinkApprove-Delivery-Attempt tells you which attempt you are looking at.
Automatic disabling
After 20 failed attempts in a row, across all its deliveries, an endpoint is set to disabled, and deliveries still queued for it are marked failed as they come due. A successful delivery resets the count.
Once your server is fixed, set status back to active with an update, then replay the failed deliveries you need. Events that happen while an endpoint is disabled are not queued for it.
attempt 1 as soon as the event happens
attempt 2 +1 minute
attempt 3 +5 minutes
attempt 4 +30 minutes
attempt 5 +2 hours
attempt 6 +6 hours
attempt 7 +12 hours
attempt 8 +24 hours → failed if this one fails tooRegister an endpoint
https://api.linkapprove.com/v1/webhookswebhooks:writeRegisters a URL and returns it with its signing secret.
urlstringrequiredA public https URL on port 443, up to 800 characters, with no username or password in it.
eventsstring[]requiredOne or more of conversion.created and conversion.updated. Duplicates are dropped.
descriptionstringYour own label, up to 255 characters.
The secret is shown once
secret straight away. No endpoint returns it again. If you lose it, rotate it.When it fails
400Validation failed: the URL is not https,eventsis empty, or it names an unknown event type.422url_not_allowed: a port other than 443, credentials in the URL, or a hostname that resolves to a private or internal address.409webhook_limit_reached: the account already has five endpoints.
curl -X POST "https://api.linkapprove.com/v1/webhooks" \
-H "Authorization: Bearer $LINKAPPROVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.trailnotes.example/linkapprove",
"events": ["conversion.created", "conversion.updated"],
"description": "Conversion sync"
}'https://api.linkapprove.com/v1/webhooksHeld in this tab's session storage only, and reused by every runner on the site.
This panel replays the documented sample response and does not call the API. Copy a code sample to send a real request with your key.
Manage endpoints
https://api.linkapprove.com/v1/webhookswebhooks:readReturns all your endpoints, newest first, as a plain array. Each one carries its status, its consecutiveFailures, when it was disabledAt, and its lastDeliveryAt, the last successful delivery.
https://api.linkapprove.com/v1/webhooks/{id}webhooks:readReturns one endpoint, or 404 webhook_not_found.
https://api.linkapprove.com/v1/webhooks/{id}webhooks:writeChanges any of these fields. Leave out the ones you are not changing.
urlstringA new URL, under the same rules as when registering.
eventsstring[]Replaces the subscribed event types.
descriptionstring | nullA new label. null clears it.
statusenumdisabled stops deliveries. active turns them back on and resets consecutiveFailures to 0.
https://api.linkapprove.com/v1/webhooks/{id}webhooks:writeDeletes the endpoint. Nothing more is delivered to it, and it no longer counts toward the limit of five.
curl "https://api.linkapprove.com/v1/webhooks" \
-H "Authorization: Bearer $LINKAPPROVE_API_KEY"https://api.linkapprove.com/v1/webhooksHeld in this tab's session storage only, and reused by every runner on the site.
This panel replays the documented sample response and does not call the API. Copy a code sample to send a real request with your key.
curl -X PATCH "https://api.linkapprove.com/v1/webhooks/7" \
-H "Authorization: Bearer $LINKAPPROVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "active"
}'https://api.linkapprove.com/v1/webhooks/7Held in this tab's session storage only, and reused by every runner on the site.
This panel replays the documented sample response and does not call the API. Copy a code sample to send a real request with your key.
Rotate the secret
https://api.linkapprove.com/v1/webhooks/{id}/rotate-secretwebhooks:writeReplaces the signing secret and returns the new one, once. The old secret stops being used immediately, including for deliveries that were already queued.
Rotate without dropping events
curl -X POST "https://api.linkapprove.com/v1/webhooks/7/rotate-secret" \
-H "Authorization: Bearer $LINKAPPROVE_API_KEY"https://api.linkapprove.com/v1/webhooks/7/rotate-secretHeld in this tab's session storage only, and reused by every runner on the site.
This panel replays the documented sample response and does not call the API. Copy a code sample to send a real request with your key.
Send a test event
https://api.linkapprove.com/v1/webhooks/{id}/testwebhooks:writeQueues a webhook.test event for this endpoint only, whatever it subscribes to, and returns 202 with the eventId and deliveryId. It is delivered within about a minute, signed like any other event. It is the quickest way to check your signature code.
curl -X POST "https://api.linkapprove.com/v1/webhooks/7/test" \
-H "Authorization: Bearer $LINKAPPROVE_API_KEY"https://api.linkapprove.com/v1/webhooks/7/testHeld in this tab's session storage only, and reused by every runner on the site.
This panel replays the documented sample response and does not call the API. Copy a code sample to send a real request with your key.
Deliveries and replay
https://api.linkapprove.com/v1/webhooks/{id}/deliverieswebhooks:readReturns this endpoint's deliveries, newest first, with what your server answered: responseStatus, the first 500 characters of responseBody, durationMs, and errorMessage when there was no response at all.
statusenumpending, retrying, succeeded or failed.
pageinteger1-based page number. Defaults to 1.
sizeintegerResults per page, 1–100. Defaults to 10.
https://api.linkapprove.com/v1/webhooks/{id}/deliveries/{deliveryId}/replaywebhooks:writeSends a succeeded or failed delivery again, with the same event id and a fresh set of eight attempts. Returns 202.
409delivery_not_replayable: it is stillpendingorretrying, and will be sent anyway.404delivery_not_found: no delivery with that ID on this endpoint.
curl "https://api.linkapprove.com/v1/webhooks/7/deliveries?page=1&size=10" \
-H "Authorization: Bearer $LINKAPPROVE_API_KEY"https://api.linkapprove.com/v1/webhooks/7/deliveries?page=1&size=10Held in this tab's session storage only, and reused by every runner on the site.
This panel replays the documented sample response and does not call the API. Copy a code sample to send a real request with your key.
curl -X POST "https://api.linkapprove.com/v1/webhooks/7/deliveries/98112/replay" \
-H "Authorization: Bearer $LINKAPPROVE_API_KEY"https://api.linkapprove.com/v1/webhooks/7/deliveries/98112/replayHeld in this tab's session storage only, and reused by every runner on the site.
This panel replays the documented sample response and does not call the API. Copy a code sample to send a real request with your key.