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
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:
| Header | Value |
|---|---|
X-VertexBridge-Timestamp | When we signed it, in whole seconds since the Unix epoch. |
X-VertexBridge-Signature | HMAC-SHA256 of the signed material, keyed with your signing secret, as 64 lowercase hexadecimal characters. |
X-VertexBridge-Event-Id | Our 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:
{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
- 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.
- Check the timestamp is within 300 seconds of your clock, in either direction. Reject it otherwise. Keep your servers' clocks synchronised.
- Compute the HMAC over
{timestamp}.{raw body}with your signing secret, and hex-encode it. - 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.
- Only then parse the body and act on it.
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)
}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 secret | example-signing-secret-not-real |
|---|---|
X-VertexBridge-Timestamp | 1762339200 |
| Raw body | {"example":true} (16 bytes, no trailing newline) |
X-VertexBridge-Signature | f7890c0bb3901ee2d2a4aa0aed0e4cdfeb6b00c1dc7108858727b731455bdad7 |
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:
| Attempt | Sent |
|---|---|
| 1 | Immediately |
| 2 | 30 seconds after attempt 1 |
| 3 | 2 minutes after attempt 2 |
| 4 | 5 minutes after attempt 3 |
| 5 | 15 minutes after attempt 4 |
| 6 | 30 minutes after attempt 5 |
| 7 | 1 hour after attempt 6 |
| 8 | 2 hours after attempt 7 |
| 9 | 6 hours after attempt 8 |
| 10 | 12 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.
curl "$VERTEXBRIDGE_API_BASE/v1/partner/deliveries?limit=20" \
-H "X-API-Key: $VERTEXBRIDGE_API_KEY"[]Until we send webhooks the log is empty. Each delivery in it is an object with these fields:
| Field | Meaning |
|---|---|
id | This delivery. Use it to replay. |
event_id | The value sent in X-VertexBridge-Event-Id. |
event_type | Which kind of event it was. Each type is documented with the endpoint that produces it. |
status | pending — waiting for its next attempt; delivered — you answered 2xx; abandoned — every attempt failed. The schema also allows failed; treat it like abandoned. |
attempts | Attempts made so far. A pending delivery with attempts above zero has failed at least once and will be retried. |
last_status_code | The HTTP status your server last answered, or null if it never answered. |
created_at, delivered_at | When 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.
curl -X POST "$VERTEXBRIDGE_API_BASE/v1/partner/deliveries/6f1d2c3b-8a4e-4f5d-9b6a-0c1e2d3f4a5b/replay" \
-H "X-API-Key: $VERTEXBRIDGE_API_KEY"{ "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.
{
"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.