Docs / Webhooks

Webhooks

When a milestone is confirmed, Bump can tell your app with a signed POST to a URL you choose. A webhook belongs to one environment, so a test webhook only gets test milestones.

Set one up

Open Integrations and choose New webhook. Give it an https:// URL and choose what it sends: All objectives, which includes objectives made later, or Selected objectives. It takes an owner or admin of the team.

The webhook's page shows its signing secret, which starts with whsec_. Your receiver needs it to check each delivery.

When a webhook goes out

When a milestone is confirmed:

  • right away, for an objective without a hold
  • after the hold, if the total is still at or above the threshold. Bump checks every five minutes, so it can arrive up to five minutes after the hold ends. A milestone voided at the end of its hold sends nothing.

Never for:

  • milestones reached by backfilled events
  • milestones a scope had already reached when the objective was made or its rule changed, marked Already reached in the dashboard

Each confirmed milestone is one delivery to each matching webhook. Objectives and milestones explains holds and both exceptions.

Payload

The body is compact JSON. This one was sent when usr_4821 passed €100 in orders, spaced out here for reading:

{
  "object": "event",
  "type": "milestone.confirmed",
  "id": "dlv_r7nj8tbp21m9csy5",
  "created": 1792339316,
  "environment": "test",
  "milestone": {
    "object": "milestone",
    "id": "mst_8s2a2wburtrffu71",
    "objective": {
      "object": "objective",
      "id": "obj_p427xtct8yn0xzlg",
      "name": "€100 in orders per identifiable"
    },
    "trackable": "orders",
    "identifiable": {
      "handle": "usr_4821"
    },
    "metric": "amount",
    "currency": "EUR",
    "threshold": "100.00",
    "tally_at_trigger": "114.49",
    "triggered_at": "2026-10-18T16:01:56Z",
    "confirmed_at": "2026-10-18T16:01:56Z",
    "event": {
      "id": "evt_48s22mf5fnwu6kzn",
      "occurred_at": "2026-10-18T16:01:56Z"
    }
  }
}
Field Means
type milestone.confirmed, or webhook.test for a test webhook.
id This delivery's ID, the same on every attempt.
created When the delivery was queued, as a Unix timestamp. The same on every attempt.
environment live or test.
milestone.id The milestone's ID. Use it to handle each milestone once.
milestone.objective The objective's ID and its name when the webhook was sent.
milestone.trackable The trackable's handle.
milestone.identifiable The handle, for an objective per identifiable. null for a project-wide one. Never the label.
milestone.metric count or amount.
milestone.currency The project's currency. Only when metric is amount.
milestone.threshold The objective's threshold. Amounts are strings with two decimals, like "100.00", in the same unit you send them. Counts are whole numbers.
milestone.tally_at_trigger The total right after the event that reached it. Here 64.50 plus 49.99.
milestone.triggered_at When Bump received that event.
milestone.confirmed_at When the milestone was confirmed. Later than triggered_at when the objective has a hold.
milestone.event The event that reached it: its ID and occurred_at.

Times are ISO 8601 in UTC, to the second. If the identifiable is forgotten, its handle here is the placeholder.

Headers

Header Value
Bump-Signature t=1792339316,v1=8216963f…, see below
Content-Type application/json
User-Agent Bump-Webhooks/1.0

Verifying the signature

Anyone who knows your URL can post to it. Check the signature before you trust a delivery.

Bump-Signature holds a timestamp t and one or more signatures v1:

t=1792339316,v1=8216963f3fc950f31302ef742ced223bb990ece6370e43bd5fbe6b98081d79d6
  1. Read the raw body, exactly as it arrived. Parse the JSON after verifying: a body that was parsed and encoded again can differ by a byte, and then it doesn't match.
  2. Split the header on commas. Take t and every v1.
  3. Compute an HMAC-SHA256 of t, a dot and the raw body, with the whole signing secret as the key, whsec_ included. Hex-encode it.
  4. Compare it to each v1 in constant time. One match is enough.
  5. Refuse a delivery whose t is more than five minutes from your clock, so a captured delivery can't be replayed later.

In plain PHP:

function verifyBumpSignature(string $body, string $header, string $secret, int $tolerance = 300): bool
{
    $timestamp = null;
    $signatures = [];

    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');

        if ($key === 't') {
            $timestamp = $value;
        } elseif ($key === 'v1') {
            $signatures[] = $value;
        }
    }

    if ($timestamp === null || ! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);

    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

$body = file_get_contents('php://input');

if (! verifyBumpSignature($body, $_SERVER['HTTP_BUMP_SIGNATURE'] ?? '', (string) getenv('BUMP_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);

In Laravel, pass $request->getContent() and $request->header('Bump-Signature', '').

In Node.js:

const crypto = require('node:crypto');

function verifyBumpSignature(body, header, secret, toleranceSeconds = 300) {
  const parts = header.split(',').map((part) => part.trim().split('='));
  const timestamp = parts.find(([key]) => key === 't')?.[1] ?? '';
  const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);

  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest();

  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'hex');

    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

With Express, read the body with express.raw({ type: 'application/json' }) and pass req.body.toString() and req.get('Bump-Signature') ?? ''. express.json() hands you parsed JSON instead of the bytes that were signed.

Rotating the secret

Rotate secret in the webhook's menu makes a new secret straight away. The old one keeps working for 24 hours: until then, every delivery carries two v1 values, one for each secret.

t=1792339335,v1=c139036b4d75a0ea46e71242673b040de388326fd2e50f7642cdd3aa268091d1,v1=08466e5c9c7d72e7c910c29b9ec3ebe8acd3e85e809a493da8fcc2606ac5faee

Accept a delivery when either matches, like the code above does, and put the new secret in your receiver within the 24 hours. The webhook's page shows when the old secret stops.

Test webhook

Send test webhook in the webhook's menu sends a real, signed delivery to the URL:

{"object":"event","type":"webhook.test","id":"dlv_2xn2bz1zfjdldfw4","created":1792339312,"environment":"test"}

It has no milestone. Answer it with a 2xx and do nothing else. It's in the delivery log like the rest, and a webhook takes five tests a minute.

Answering

Any 2xx counts as delivered. Bump reads the status code and nothing else, so what you send back doesn't matter. Answer within 10 seconds, and do slow work after answering, for example in a queued job.

Retries

A delivery that doesn't get a 2xx is tried again, up to 10 attempts, each counted from the first:

Attempt After the first
1 right away
2 1 minute
3 3 minutes
4 7 minutes
5 15 minutes
6 31 minutes
7 1 hour
8 2 hours
9 4 hours
10 26 hours

Every failure is retried: a refusal, a timeout, a connection that fails, a redirect, any status that isn't a 2xx. Live and test webhooks retry the same way. An attempt can arrive up to a minute after its time.

Each attempt goes to the webhook's URL as it is when the attempt is sent, so fixing a wrong URL sends the remaining attempts to the right one.

Each attempt is signed when it's sent, so its t is fresh. The id, created and milestone.id stay the same, so a receiver that handles each milestone once skips a retry of one it already handled. That happens when your receiver did the work but answered too late. If the identifiable was forgotten in between, a later attempt carries the placeholder.

While a delivery waits for its next attempt, Developers > Deliveries shows it as Retrying, with how many attempts it has had (3 of 10) and when the next one is due.

After the 10th failed attempt the delivery gives up. It shows as failed in Developers > Deliveries, and Developers > Problems lists Webhook gave up after 10 attempts, one line per webhook per day.

The test webhook is sent once and never retried.

Resend

Owners and admins can send a delivery again from its details in Developers > Deliveries: Resend, once it has succeeded or failed. It goes out right away as one more attempt, signed fresh and built from the milestone as it is now. The id, created and milestone.id stay the same, so your receiver can tell it's a repeat.

A resend is one attempt, and nothing is retried after it. If it fails, a delivery that had already arrived stays succeeded, and one that hadn't stays failed. The test webhook isn't resent: send a new test instead. Each resend is in Developers > Audit log.

Why your receiver sees nothing

Bump refuses some deliveries before sending anything:

  • HTTPS only. The URL has to start with https://, and the certificate has to verify.
  • Public addresses only. Bump looks up the host and refuses the delivery when any of its addresses is private, loopback, link-local, carrier-grade NAT, multicast, reserved, or a cloud metadata address. Then it connects only to the addresses it checked. A receiver on localhost or an office network can't be reached. To try one on your laptop, put it behind a tunnel with a public HTTPS URL.

And some fail once Bump has connected:

  • No redirects. A 3xx is a failed attempt. Use the URL your receiver ends up at.
  • 10 seconds. No answer by then is a failed attempt, even if your receiver finishes later.
  • Anything else that isn't a 2xx.

Every delivery shows on the webhook's page, which lists its ten latest, and in Developers > Deliveries, which lists them all with the status, every attempt with its response code or error, and the exact body of the latest attempt. Bodies are kept for 13 months.

Error Means
Refused: not HTTPS The URL isn't https://.
Refused: host not found The host doesn't resolve.
Refused: private address The host resolves to an address that isn't public.
Timed out after 10s No answer within 10 seconds.
Couldn’t connect (cURL error 60) The connection failed. The number is cURL's error code: 60 is a certificate that doesn't verify.
Expected 2xx, got 500 Your receiver answered with that status.
Expected 2xx, got 302 (redirects aren’t followed) Your receiver redirected.
Not sent: the delivery job failed Something broke on our side while sending.

Handle each milestone once

Store milestone.id with whatever you do for a milestone, like a reward, and skip one you've seen. Two webhooks pointing at the same receiver send the same milestone with two different ids, but one milestone.id.