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 2xx within 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

Return 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.
Request
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.
data.conversion
idinteger

The conversion's LinkApprove ID. Stable across every event for the same sale.

programIdinteger | null

The program it was earned on.

channelIdinteger | null

The channel that sent the click.

statusenum

The current status: pending, approved, available, ready_for_payout or rejected.

saleAmountstring | null

The order value, as the network reported it.

commissionstring | null

Your share of the commission, as the network reported it.

currencystring | null

ISO 4217 code for both amounts. Null when the network did not say.

occurredAtstring | null

When 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 v1 in constant time, and reject the request if t is more than five minutes away from your clock.

Sign the raw body

Parsing the JSON and re-serialising it changes whitespace and key order, and the signature will never match. Read the body as bytes.
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.

Schedule
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 too

Register an endpoint

POSThttps://api.linkapprove.com/v1/webhookswebhooks:write

Registers a URL and returns it with its signing secret.

Body parameters
urlstringrequired

A public https URL on port 443, up to 800 characters, with no username or password in it.

eventsstring[]required

One or more of conversion.created and conversion.updated. Duplicates are dropped.

descriptionstring

Your own label, up to 255 characters.

The secret is shown once

Store secret straight away. No endpoint returns it again. If you lose it, rotate it.

When it fails

  • 400 Validation failed: the URL is not https, events is empty, or it names an unknown event type.
  • 422 url_not_allowed: a port other than 443, credentials in the URL, or a hostname that resolves to a private or internal address.
  • 409 webhook_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"
  }'
Try it
POSThttps://api.linkapprove.com/v1/webhooks

Held 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

GEThttps://api.linkapprove.com/v1/webhookswebhooks:read

Returns 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.

GEThttps://api.linkapprove.com/v1/webhooks/{id}webhooks:read

Returns one endpoint, or 404 webhook_not_found.

PATCHhttps://api.linkapprove.com/v1/webhooks/{id}webhooks:write

Changes any of these fields. Leave out the ones you are not changing.

Body parameters
urlstring

A new URL, under the same rules as when registering.

eventsstring[]

Replaces the subscribed event types.

descriptionstring | null

A new label. null clears it.

statusenum

disabled stops deliveries. active turns them back on and resets consecutiveFailures to 0.

DELETEhttps://api.linkapprove.com/v1/webhooks/{id}webhooks:write

Deletes 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"
Try it
GEThttps://api.linkapprove.com/v1/webhooks

Held 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"
  }'
Try it
PATCHhttps://api.linkapprove.com/v1/webhooks/7

Held 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

POSThttps://api.linkapprove.com/v1/webhooks/{id}/rotate-secretwebhooks:write

Replaces 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

For a moment after rotating, accept a signature that matches either the old or the new secret. Then remove the old one.
curl -X POST "https://api.linkapprove.com/v1/webhooks/7/rotate-secret" \
  -H "Authorization: Bearer $LINKAPPROVE_API_KEY"
Try it
POSThttps://api.linkapprove.com/v1/webhooks/7/rotate-secret

Held 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

POSThttps://api.linkapprove.com/v1/webhooks/{id}/testwebhooks:write

Queues 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"
Try it
POSThttps://api.linkapprove.com/v1/webhooks/7/test

Held 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

GEThttps://api.linkapprove.com/v1/webhooks/{id}/deliverieswebhooks:read

Returns 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.

Query parameters
statusenum

pending, retrying, succeeded or failed.

pageinteger

1-based page number. Defaults to 1.

sizeinteger

Results per page, 1–100. Defaults to 10.

POSThttps://api.linkapprove.com/v1/webhooks/{id}/deliveries/{deliveryId}/replaywebhooks:write

Sends a succeeded or failed delivery again, with the same event id and a fresh set of eight attempts. Returns 202.

  • 409 delivery_not_replayable: it is still pending or retrying, and will be sent anyway.
  • 404 delivery_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"
Try it
GEThttps://api.linkapprove.com/v1/webhooks/7/deliveries?page=1&size=10

Held 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"
Try it
POSThttps://api.linkapprove.com/v1/webhooks/7/deliveries/98112/replay

Held 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.