Skip to content
VertexBridgeDocs
On this page

Webhooks

We tell your server when something happens, by HTTP POST. Every delivery is signed, carries a timestamp and an event id, and is retried until you acknowledge it.

Delivery status

We are not sending webhooks yet.

No endpoint publishes events today, and webhook endpoints cannot be registered yet, so no delivery will reach you. What this page describes is the signing and retry behaviour of our webhook code as it exists now. Build and test your verifier against it — the test vector below was produced by our own signer — and it will accept our deliveries when they start. Each event's name and body will be documented with the endpoint that produces it.

Each account will have one webhook endpoint, and a signing secret for it. Treat that secret exactly like an API key: in a secret store, never in source code.

How a delivery is signed

Each delivery carries three headers:

HeaderValue
X-VertexBridge-TimestampWhen we signed it, in whole seconds since the Unix epoch.
X-VertexBridge-SignatureHMAC-SHA256 of the signed material, keyed with your signing secret, as 64 lowercase hexadecimal characters.
X-VertexBridge-Event-IdOur id for the event, the same on every attempt. Deduplicate on it.

The signed material is the timestamp, a full stop, and the request body exactly as sent:

Signed material
{timestamp}.{raw request body}

Because the timestamp is inside the signature, it cannot be changed to slip an old delivery past your replay check — and because the body is too, the signature is valid for exactly one body.

Verify a delivery

  1. Read the raw body before anything parses it. Parsing JSON and serialising it again changes the bytes — spacing, key order, escaping — and the signature will no longer match. Most frameworks parse by default; switch that off for this one route.
  2. Check the timestamp is within 300 seconds of your clock, in either direction. Reject it otherwise. Keep your servers' clocks synchronised.
  3. Compute the HMAC over {timestamp}.{raw body} with your signing secret, and hex-encode it.
  4. Compare in constant time. An ordinary string comparison stops at the first difference, and the time it takes leaks how much of a forged signature was right.
  5. Only then parse the body and act on it.
Verifier
import { createHmac, timingSafeEqual } from 'node:crypto'

const REPLAY_WINDOW_SECONDS = 300

/**
 * True only for a delivery we signed, sent within the replay window.
 * rawBody must be the request body exactly as received — a Buffer or string,
 * never JSON you have parsed and re-serialised.
 */
export function verifyVertexBridgeWebhook({ secret, rawBody, timestampHeader, signatureHeader, now = Date.now() }) {
  if (typeof timestampHeader !== 'string' || typeof signatureHeader !== 'string') return false
  if (!/^[0-9]+$/.test(timestampHeader)) return false
  const timestamp = Number(timestampHeader)
  if (Math.abs(Math.floor(now / 1000) - timestamp) > REPLAY_WINDOW_SECONDS) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(signatureHeader)
  return a.length === b.length && timingSafeEqual(a, b)
}
Wiring it to a route
import express from 'express'

const app = express()

// express.raw, not express.json: the signature covers the bytes we sent.
app.post('/webhooks/vertexbridge', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyVertexBridgeWebhook({
    secret: process.env.VERTEXBRIDGE_WEBHOOK_SECRET,
    rawBody: req.body,
    timestampHeader: req.get('X-VertexBridge-Timestamp'),
    signatureHeader: req.get('X-VertexBridge-Signature'),
  })
  if (!ok) return res.sendStatus(400)

  const eventId = req.get('X-VertexBridge-Event-Id')
  // Record eventId; if you have seen it before, stop here and still answer 200.
  queueForProcessing(eventId, req.body)
  res.sendStatus(200)
})

A delivery that fails verification gets a 400 and nothing else. Do not log the body of a delivery you could not verify; you cannot know who wrote it.

Test your verifier

These values were produced by our signing code. A correct verifier accepts them when its clock reads the timestamp — pass now explicitly — and rejects them if any single byte changes.

Signing secretexample-signing-secret-not-real
X-VertexBridge-Timestamp1762339200
Raw body{"example":true} (16 bytes, no trailing newline)
X-VertexBridge-Signaturef7890c0bb3901ee2d2a4aa0aed0e4cdfeb6b00c1dc7108858727b731455bdad7

Duplicates and order

Delivery is at least once. If your server took the event but its answer never reached us, we send it again with the same X-VertexBridge-Event-Id. Store the event ids you have processed, and when one repeats, answer 200 and do nothing else.

Delivery is not ordered. A retry can arrive after a later event's first attempt. Never assume the newest delivery describes the newest state; when order matters, read the current state from the API.

Responding

Any 2xx status means delivered, and we stop. Anything else — another status, a refused connection, no answer — counts as a failed attempt and is retried.

Answer as soon as the delivery is verified and recorded, and do the work afterwards. An endpoint that does its processing before it answers turns every slow day into a wave of retries.

Retries

A delivery is attempted up to 10 times, each attempt waiting longer than the last, so that an endpoint that is down for a deploy recovers without losing anything:

AttemptSent
1Immediately
230 seconds after attempt 1
32 minutes after attempt 2
45 minutes after attempt 3
515 minutes after attempt 4
630 minutes after attempt 5
71 hour after attempt 6
82 hours after attempt 7
96 hours after attempt 8
1012 hours after attempt 9

If attempt 10 fails too — about 21 hours 53 minutes after the first — we stop, and the delivery is marked abandoned. It stays in your delivery log, and you can send it again.

The delivery log

Every delivery queued for you, newest first. Needs the read scope; limit defaults to 50 and is capped at 200.

GET /v1/partner/deliveries
curl "$VERTEXBRIDGE_API_BASE/v1/partner/deliveries?limit=20" \
  -H "X-API-Key: $VERTEXBRIDGE_API_KEY"
200 OK — today
[]

Until we send webhooks the log is empty. Each delivery in it is an object with these fields:

FieldMeaning
idThis delivery. Use it to replay.
event_idThe value sent in X-VertexBridge-Event-Id.
event_typeWhich kind of event it was. Each type is documented with the endpoint that produces it.
statuspending — waiting for its next attempt; delivered — you answered 2xx; abandoned — every attempt failed. The schema also allows failed; treat it like abandoned.
attemptsAttempts made so far. A pending delivery with attempts above zero has failed at least once and will be retried.
last_status_codeThe HTTP status your server last answered, or null if it never answered.
created_at, delivered_atWhen the event was queued, and when you acknowledged it (null until then).

The log holds the count of attempts and the last answer, not a history of each attempt.

Replaying a delivery

Once a delivery is abandoned, you can have it sent again — after fixing your endpoint, for example. It goes back to pending and starts the retry schedule from attempt 1, with the same event id, so your deduplication still recognises it.

POST /v1/partner/deliveries/{delivery_id}/replay
curl -X POST "$VERTEXBRIDGE_API_BASE/v1/partner/deliveries/6f1d2c3b-8a4e-4f5d-9b6a-0c1e2d3f4a5b/replay" \
  -H "X-API-Key: $VERTEXBRIDGE_API_KEY"
200 OK
{ "delivery_id": "6f1d2c3b-8a4e-4f5d-9b6a-0c1e2d3f4a5b", "status": "pending" }

Only an abandoned (or failed) delivery can be replayed. A delivery that is still pending is already being retried, and one that was delivered reached you; both answer 404 not_found, as does an id that is not yours — the same answer, so the endpoint cannot be used to discover which ids exist.

The recipe, from the API

GET /v1/partner/webhook-signing returns the header names and replay window from the same constants our signer uses. Read it in a test of your own if you want to know the moment anything changes.

200 OK
{
  "algorithm": "HMAC-SHA256",
  "signed_material": "{timestamp}.{raw_request_body}",
  "signature_header": "X-VertexBridge-Signature",
  "timestamp_header": "X-VertexBridge-Timestamp",
  "event_id_header": "X-VertexBridge-Event-Id",
  "replay_window_seconds": 300,
  "notes": [
    "Compare signatures in constant time.",
    "Reject a delivery whose timestamp is outside the replay window.",
    "Deduplicate on the event id header — we retry, so you will see the same event more than once.",
    "Verify against the RAW body bytes; re-serialising JSON changes them."
  ]
}

Next: Errors.