Webhooks

Signed HTTP deliveries to your server when something happens to a call, a target or the wallet — with verification, retries, a delivery log and replay.

After this page you can stand up a receiver that takes deliveries from the platform, prove each one really came from us, and know exactly what happens when your server is down. A webhook is a request the platform makes to your server when something happens: a call connected, a call ended unrouted, a target hit its cap.

You give it a URL and choose the events. It signs every delivery, retries the ones that fail, marks itself degraded when your endpoint stops answering, and keeps every attempt in a log you can replay from. Webhooks are on plans that include them — see pricing.

Setting one up

  1. 1

    Have a URL that answers

    Public, https, and returning a 2xx fast. It is resolved when you save it and again at every send, and hosts that resolve to private, loopback or link-local addresses are refused. Redirects are never followed — give the final URL.

  2. 2

    Create the endpoint

    On Webhooks & pixels in the console, or with one call. Name it after what consumes it: the name is what you will read in the activity log and in an alert at three in the morning.

    Create a webhook
    curl -X POST "https://api.buy3.io/api/tracking/webhooks" \
      -H "Authorization: Bearer $BUY3_SESSION_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Order book — production",
        "kind": "webhook",
        "url": "https://hooks.example.com/buy3",
        "eventPattern": ["call.completed", "call.converted", "call.unrouted"],
        "note": "Feeds the nightly reconciliation job"
      }'
  3. 3

    Store the signing secret

    It is in the answer, once. Only a hash is kept, so it cannot be shown again — if it is lost, rotate it.

    201 Created
    {
      "webhook": {
        "id": "b7c6d5e4-f3a2-4b19-8c07-6d5e4f3a2b19",
        "name": "Order book — production",
        "kind": "webhook",
        "url": "https://hooks.example.com/buy3",
        "method": "POST",
        "eventPattern": ["call.completed", "call.converted", "call.unrouted"],
        "status": "active",
        "campaignId": null,
        "campaignName": null,
        "publisherId": null,
        "publisherName": null,
        "headers": {},
        "bodyTemplate": null,
        "signed": true,
        "secretHint": "whsec_Zk3q7••••••••8Xa2",
        "deliveredCount": 0,
        "failedCount": 0,
        "stats": {
          "window": "24h",
          "attempts": 0, "delivered": 0, "failed": 0,
          "p50": 0, "p95": 0,
          "lastStatus": null, "lastOk": null, "lastAt": null
        }
      },
      "signingSecret": "whsec_Zk3q7nR2vYdT1pLwMc0bJfHsXeQu8Xa2",
      "note": "Store this signing secret now — it is shown only once.",
      "notes": []
    }
  4. 4

    Send a test

    Send test queues a delivery built from your newest real call, or from sample data if you have none yet, and marks it test: true so your receiver can drop it. It is queued, not sent inline — watch the delivery log for the result, within about fifteen seconds.

  5. 5

    Verify, then handle

    Check the signature before you parse anything. The code is below.

  • eventPattern takes up to twenty event names, or "*" for everything. The names are exact — there is no family form, so call.* is refused; list the call events you want, or take them all with "*".
  • campaignId or publisherId narrows the endpoint to one campaign or one publisher, which is how a partner's system is sent their calls and nobody else's.
  • headers adds up to twenty headers of your own — a bearer token your receiver expects, say. Hop-by-hop headers and anything starting buy3- are refused, so a receiver that trusts buy3-signature is never reading a value a customer typed.
  • An endpoint you pause hears nothing. Events that fire while it is paused are not queued for it and cannot be replayed afterwards; pausing stops traffic rather than hiding it.

What a delivery looks like

Every webhook delivery is a POST of the same four-field JSON envelope. id is unique to the event and stays the same across every retry and every replay, so it is what you de-duplicate on. What is inside data depends on the event — see Events.

The request your server sees
POST /hooks/buy3 HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: buy3-webhooks/1
buy3-event: call.completed
buy3-request-id: req_5f0c2a91b7d34e10
buy3-attempt: 2
buy3-signature: t=1789913184,v1=9c1185a5c5e9fc5476a02d0b3e6c0d4f1a2b3c4d5e6f708192a3b4c5d6e7f809
HeaderMeaning
buy3-eventThe event name, so you can route before parsing the body.
buy3-request-idIdentifies this event across all of its delivery attempts. It is also the envelope's id.
buy3-attempt1 for the first try, counting up on each retry. A replay carries a number past the ceiling.
buy3-signaturet=<unix seconds>,v1=<hex> — see Verifying a delivery.
user-agentbuy3-webhooks/1. Useful in an access log; it is not proof of anything.
Your own headers are added first, so none of these can be overwritten by one.

Verifying a delivery

Anybody can POST JSON at your URL. The signature is how you know a delivery came from us, and it is the only thing that does — the user agent, the source address and the shape of the body are all things somebody else can produce.

buy3-signature holds two fields separated by a comma: t, a Unix timestamp in seconds, and v1, a lower-case hex HMAC-SHA256. The key is your endpoint's signing secret and the message is the timestamp, a full stop, and the raw request body:

The string that is signed
v1 = HMAC_SHA256(signing_secret, "<t>" + "." + <raw request body>)
  1. Split the header on , and then each piece on = to get t and v1. Do not assume the order or that there are exactly two.
  2. Check t is recent. Five minutes either way is a good tolerance. This is the step that stops a replay: the timestamp is inside the signed string, so a captured body cannot be re-sent at you tomorrow — but only if you look at it.
  3. Compute the HMAC over t, a full stop, and the raw bytes of the body. A re-serialised object is not the same bytes: key order and whitespace both change, and the signature will not match.
  4. Compare in constant time. === on a hex string leaks how much of it was right.
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.BUY3_WEBHOOK_SECRET;   // whsec_…
const TOLERANCE_SECONDS = 300;

/** true when this body really came from buy3 and is not a replayed capture. */
export function verify(rawBody, header, secret = SECRET) {
  // header: "t=1789913184,v1=9c1185a5c5e9fc54…"
  const parts = Object.fromEntries(
    String(header ?? "").split(",").map((p) => p.split("=", 2)),
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1;
  if (!Number.isFinite(timestamp) || !signature) return false;

  // The timestamp is inside the signed string, so a captured body cannot be
  // replayed at you later with a fresh clock — but only if you check its age.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// The RAW bytes, not a re-serialised object: JSON.stringify(req.body) reorders
// keys and drops whitespace, and the signature is over what was actually sent.
app.post("/hooks/buy3", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body.toString("utf8"), req.header("buy3-signature"))) {
    return res.status(400).send("bad signature");
  }

  const { id, event, data } = JSON.parse(req.body.toString("utf8"));
  res.status(204).end();          // answer first, work afterwards

  void handle(id, event, data);
});

app.listen(3000);
  • A GET pixel carries no signature. There is no body to sign, and a signature over an empty string would mean nothing. Every POST — webhook or pixel — is signed.
  • Rotating the secret is immediate. There is one secret per endpoint: the moment you rotate, deliveries are signed with the new value and a receiver checking signatures rejects them until it has it. Deploy the new secret first, then rotate.
  • The secret is shown once, when the endpoint is created and again each time you rotate. The console only ever shows you a masked hint afterwards.

Answering a delivery

Your answerWhat happens
2xxDelivered. The endpoint's success counter moves, and a degraded endpoint goes back to active on the spot.
3xxFailed. Redirects are not followed; the log records which location you offered and says to point the endpoint at the final URL.
4xxFailed and retried, exactly like a 5xx. A permanent refusal still costs four attempts, so refuse loudly — the first 2 KB of your body is kept in the log.
5xxFailed and retried.
No answer in 10 secondsFailed. The log records a time-out, and the status is 0 rather than an HTTP code.
Refused connection, DNS failure, a host we may not callFailed, with 0 again and the reason in the log.

Retries

A failed delivery is retried on a fixed schedule. Four attempts in all, so three waits:

AttemptSent
1As soon as the dispatcher picks the event up — within about fifteen seconds of it happening.
21 second after the first failure.
35 seconds after the second.
425 seconds after the third.
The fourth failure schedules nothing. The delivery is dropped, and only a human replay revives it.
Every attempt carries the same buy3-request-id, with buy3-attempt counting up. The whole schedule is over in about half a minute: it is for a blip, not for an outage.
  • Retries are not a queue for your downtime. Half a minute of retries does not cover a deploy. What covers a deploy is the log and the replay button: nothing is lost, it just waits for you.
  • An endpoint that did not answer at all is set aside for the rest of that dispatcher pass, so twenty-five queued events for one dark host do not become four minutes in which nobody else's webhook moves.
  • A delivery can, rarely, arrive twice under the same request id — a process that died between your answer and our record of it. That is the other reason to de-duplicate.
  • Deliveries can arrive out of order. Order on the instants inside data, never on arrival.

Degraded

An endpoint that fails two deliveries in a row is marked degraded. Nothing stops: deliveries keep being attempted and keep being retried. What changes is that the console says so, the change is written to the activity log, and somebody can look before a partner files a ticket.

StatusMeansSet by
activeWorking, as far as the last two deliveries know.You, or the first success after a run of failures.
degradedTwo consecutive failures. Still delivering.The dispatcher. It is earned, not chosen.
pausedHears nothing at all. Events that fire meanwhile are never queued.You.
  • The first success clears it, with no intervention. Recovery should not need a human; only the failure should.
  • A paused endpoint stays paused whatever happens. A failure never overwrites somebody's decision to switch something off.
  • You cannot set an endpoint to degraded yourself, and resuming a degraded one sets it active and lets the next delivery say whether that was true.

Replay

Replay is the human override of the give-up rule. The original event and its original payload are queued again under the same request id, with the attempt counter carried forward — so the log shows a fifth attempt on an event the machine stopped at four, which is exactly what a manual replay should leave behind.

ActionDoes
Replay on one deliveryQueues that event again. Two clicks are one replay: a replay already waiting is handed back rather than stacked.
Replay failures on an endpointQueues the latest failed attempt of every event that never succeeded, newest first, up to 200 at a time.
Send testQueues a sample event without waiting for a real call.
  • Replay failures works per event, not per attempt. A four-attempt failure replayed four times would be an outage of your own making.
  • Anything that succeeded later under the same request id is skipped. A receiver is never sent an event it has already taken.
  • A paused endpoint refuses both: resume it first, since a paused endpoint is never called and the replay would sit in the log for ever.
  • Replaying a degraded endpoint does not declare it healthy. It proves that on the next delivery.

Worked example: your receiver was down for an hour

A deploy went wrong at 09:00 and your receiver returned 502 until 10:00. Roughly 180 calls completed in that hour, so 180 events each made four attempts and were dropped. Nothing was lost — every one of them is in the delivery log.

  1. Open the endpoint. It is degraded, and the 24-hour tiles show the failures against the deliveries.
  2. Filter the delivery log to Failed. Each row is one event with its attempts inside it, not four unrelated failures.
  3. Check one: the response body shows your 502 page, which is how you confirm it was the deploy and not a signature problem.
  4. Press Replay failures. It queues one replay per event — 180, not 720 — and skips anything that had already succeeded on a later retry.
  5. Watch the log. The first success flips the endpoint back to active on its own.
  6. If your handler is idempotent on buy3-request-id, you can press it without thinking about what was half-processed. If it is not, make it so before you need it again.

Had the outage been eight hours rather than one, the answer is the same — the log is the record, and the replay is per event however old. What the retries cannot do is wait for you, which is the whole reason the log exists.

Where to look when nothing arrives

  • Is the endpoint paused? A paused endpoint has no rows at all for the period, because nothing was ever queued.
  • Does the pattern match? An endpoint subscribed to call.completed hears nothing from a campaign whose calls all end unrouted.
  • Is it narrowed? An endpoint pinned to a campaign does not hear an event that names no campaign, and one pinned to a publisher never hears a call from your own media.
  • Does your plan still include webhooks? A workspace that downgrades keeps its endpoints exactly as they are and stops emitting to them. Nothing is deleted, and coming back on the plan starts them again.
  • Are there rows with status 0? Then we could not reach you at all: DNS, TLS, a refused connection, or an address the safety check will not call.
  • Is your 2xx actually a 2xx? A framework that answers 301 to http traffic, or 401 to a request without a session, is a failure from here.

The vocabulary, from the API

GET /api/v1/webhooks/events answers with everything an endpoint editor needs, and it is the one webhook route on the REST API — any valid key may read it, since the answer is the same for every workspace. It carries the event list with descriptions, the macros a pixel may use, a full sample envelope and payload, the four buy3- headers, and the retry schedule as the dispatcher actually runs it.

Read the event and macro vocabulary
curl -s "https://api.buy3.io/api/v1/webhooks/events" \
  -H "Authorization: Bearer $BUY3_API_KEY"

Creating, editing, testing and replaying are console routes under /api/tracking/webhooks, called with a signed-in owner's or admin's session. An API key cannot create an endpoint — a credential that could mint a new place to send your calls is a larger thing than a credential that can read them.

Next steps