Appearance
Webhooks
OnPoint delivers real-time notifications to your HTTPS endpoint as deliveries progress. Webhooks remove the need to poll for status changes.
Setup
- Provide an HTTPS URL with a valid TLS certificate. Plain HTTP is rejected.
- Configure the endpoint in the OnPoint dashboard (Settings → Webhooks).
- Store the signing secret securely — you need it to verify payloads. Secrets are issued as
whsec_followed by a base64 value; strip the prefix before using it as HMAC key material.
You may register separate URLs for delivery events and driver (fleet) events, or handle both at one URL and branch on eventType.
Request format
OnPoint sends POST requests with Content-Type: application/json.
Headers
OnPoint implements the Standard Webhooks specification. Verification libraries exist for most languages, and any of them will work unmodified.
| Header | Description |
|---|---|
webhook-id | Unique event identifier. Same value as eventId in the body. Use as your dedupe key. |
webhook-timestamp | Unix timestamp in seconds at which the event was sent |
webhook-signature | Space-separated list of v1,<base64> signatures |
Body envelope
All delivery webhooks share this shape:
json
{
"eventId": "evt_01JXYZ9ABCDEF",
"eventType": "delivery.driver_assigned",
"occurredAt": "2026-07-26T21:18:04Z",
"previousStatus": "accepted",
"delivery": { }
}| Field | Description |
|---|---|
eventId | Dedupe key. Process each eventId at most once. |
eventType | Namespaced event identifier (see catalog below) |
occurredAt | When the event happened (UTC) |
previousStatus | Prior delivery status, when applicable |
delivery | Full delivery snapshot at time of event |
Driver webhooks use the same headers with driver instead of delivery:
json
{
"eventId": "evt_01JXYZ9DRIVER1",
"eventType": "driver.duty_changed",
"occurredAt": "2026-07-26T14:00:00Z",
"driver": { }
}Verifying signatures
The signed payload is the concatenation of three values separated by periods:
{webhook-id}.{webhook-timestamp}.{rawBody}Compute HMAC-SHA256 of that string with your signing secret, base64-encode it, and compare against each v1, entry in webhook-signature using a constant-time comparison.
Signing the id and timestamp — not just the body — is what makes replay protection real. A captured event cannot be re-sent with a rewritten timestamp, because the timestamp is inside the signature.
Replay protection: Reject requests where webhook-timestamp is more than 5 minutes from your server clock in either direction.
Use the raw bytes. Verify before any JSON parsing or body-parser middleware touches the request. Re-serializing the JSON will change the bytes and the signature will not match.
Secret rotation
During rotation OnPoint sends multiple signatures in one header, one per active secret:
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm90LXRoZS1zYW1lLXNpZ25hdHVyZS1oZXJlLWF0LWFsbCE=Accept the request if any signature matches any of your active secrets. This lets you roll a secret with no dropped events: add the new secret, wait for the overlap window to close, then remove the old one.
Node.js example
javascript
import crypto from "crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* @param rawBody Buffer or string — the exact bytes received, before JSON parsing
* @param headers Incoming request headers
* @param secrets One or more active signing secrets (base64, without the `whsec_` prefix)
*/
function verifyOnPointWebhook(rawBody, headers, secrets) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const header = headers["webhook-signature"];
if (!id || !timestamp || !header) return false;
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false;
const signedPayload = `${id}.${timestamp}.${rawBody}`;
const expected = secrets.map((secret) =>
crypto
.createHmac("sha256", Buffer.from(secret, "base64"))
.update(signedPayload)
.digest("base64"),
);
const received = header
.split(" ")
.filter((part) => part.startsWith("v1,"))
.map((part) => part.slice(3));
return received.some((candidate) =>
expected.some((e) => constantTimeEquals(candidate, e)),
);
}
function constantTimeEquals(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
// timingSafeEqual throws on length mismatch — check first.
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}Express
javascript
app.post(
"/webhooks/onpoint",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verifyOnPointWebhook(req.body, req.headers, ACTIVE_SECRETS)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
enqueueForProcessing(event); // return fast, process asynchronously
res.sendStatus(200);
},
);Delivery event catalog
Status transition events
These fire once per transition. Each maps to a DeliveryStatus value:
| eventType | Delivery status | Description |
|---|---|---|
delivery.accepted | accepted | Delivery created and dispatch started |
delivery.dispatch_failed | dispatch_failed | No driver could be assigned |
delivery.scheduled | scheduled | Scheduled for a future window |
delivery.reassigning | reassigning | Being re-dispatched — not a cancellation |
delivery.driver_assigned | driver_assigned | Driver assigned |
delivery.driver_enroute_to_pickup | driver_enroute_to_pickup | Driver heading to pickup |
delivery.driver_at_pickup | driver_at_pickup | Driver arrived at pickup |
delivery.pickup_complete | pickup_complete | Pickup completed |
delivery.driver_enroute_to_dropoff | driver_enroute_to_dropoff | Driver heading to customer |
delivery.driver_at_dropoff | driver_at_dropoff | Driver arrived at customer |
delivery.delivered | delivered | Successfully delivered |
delivery.failed | failed | Delivery failed |
delivery.return_in_progress | return_in_progress | Return to store in progress |
delivery.returned | returned | Returned to store |
delivery.cancelled | cancelled | Cancelled |
delivery.expired | expired | Expired without completion |
Informational events (no status change)
These may fire multiple times during a delivery:
| eventType | Description |
|---|---|
delivery.driver_location_updated | Driver GPS updated (~every 1–2 min when available) |
delivery.eta_updated | Pickup or dropoff ETA changed |
delivery.proof_of_delivery_added | Photo, signature, barcode, or pincode captured |
Driver event catalog
Available when fleet management is enabled for your account:
| eventType | Description |
|---|---|
driver.created | New driver added to roster |
driver.updated | Driver profile changed |
driver.deleted | Driver removed |
driver.duty_changed | On-duty / off-duty status changed |
Installation event catalog
Sent when you deliver to military installations.
| eventType | Description |
|---|---|
installation.access_policy_changed | Access policy changed at an installation you deliver to |
Subscribe to this rather than polling GET /installations. Force protection posture, gate hours, and commercial-delivery suspensions can change with under 24 hours' notice — there are documented cases of an installation tightening access overnight and turning away roughly 150 delivery drivers over a single weekend, because none of the platforms carrying those orders knew.
The payload carries a changed[] array so you can ignore updates you do not care about, and affectedDeliveryCount for your in-flight and scheduled orders to that installation. When it fires, re-run POST /serviceability against your scheduled orders and notify affected customers before a driver is standing at a gate.
json
{
"eventId": "evt_01K1FPCON01",
"eventType": "installation.access_policy_changed",
"occurredAt": "2026-07-27T04:12:00Z",
"livemode": true,
"changed": ["fpconLevel", "accessLevel"],
"affectedDeliveryCount": 3,
"installation": {
"code": "INST-FTMEADE",
"name": "Fort Meade",
"acceptingDeliveries": true,
"accessLevel": "escort_required",
"fpconLevel": "bravo",
"photographyPermitted": false
}
}Affected in-flight deliveries also emit their own delivery.* events; this one is about the installation, not any single order.
Sample: driver assigned
See examples/webhook-driver-assigned.json.
json
{
"eventId": "evt_01K1DRIVERASSIGN",
"eventType": "delivery.driver_assigned",
"occurredAt": "2026-07-26T21:18:04Z",
"previousStatus": "accepted",
"delivery": {
"deliveryId": "del_7xKGR6PW2pBtuTeRg2b7Bw",
"clientOrderReference": "ORDER-48291",
"status": "driver_assigned",
"driver": {
"id": "drv_a1b2c3d4",
"name": "Alex Rivera",
"phoneNumber": "+14155550123",
"vehicleDescription": "White Honda Civic"
},
"trackingUrl": "https://track.getonpoint.io/d/del_7xKGR6PW2pBtuTeRg2b7Bw"
}
}Best practices
- Return
200quickly — acknowledge receipt, then process asynchronously. Verifying the signature is the only work that belongs in the request handler. - Dedupe on
eventId— delivery is at-least-once, so the same event can arrive more than once. - Do not assume ordering. Retries mean a redelivered
driver_assignedcan land afterdelivered. Order byoccurredAt, not arrival time, and ignore backward transitions: if you have already recordedpickup_complete, drop a latedriver_at_pickup. - Treat the embedded
deliveryobject as authoritative over your own accumulated state — it is a full snapshot at the time of the event, so a single late event cannot silently corrupt a field. delivery.reassigningis not terminal — expect a subsequentdelivery.driver_assigned.- Do not treat informational events as terminal —
driver_location_updateddoes not mean delivered. - Handle unknown
eventTypevalues gracefully. New event types are added without a version bump; log and ignore anything you don't recognize rather than erroring, which would trigger pointless retries. - Check
livemode. Sandbox events — including those fromPOST /deliveries/{id}/simulate— carrylivemode: false. Asserting on it means a misconfigured sandbox endpoint pointed at production can never quietly move real orders. - Branch on
statusReasonCode, notstatusReason. The prose changes without notice. In particular, do not lump the gate-access codes in withrecipient_unavailable: a driver denied entry is a scheduling or credential problem, and retrying the order unchanged will fail again.
Retries
Failed deliveries (non-2xx, timeout, or connection error) are retried with exponential backoff. Eight attempts are made in total, spanning roughly 28 hours:
| Attempt | Delay before this attempt |
|---|---|
| 1 | Immediately |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
Respond within 10 seconds; slower responses are treated as timeouts and retried.
After repeated failures, OnPoint may disable the endpoint and alert your account contact.
Recovering missed events
If your endpoint was down past the retry window, fetch the events you missed directly. OnPoint retains every event for 30 days and can tell you which ones never reached you:
bash
curl "https://api.getonpoint.io/delivery/v1/events?delivered=false&since=2026-07-26T00:00:00Z" \
-H "Authorization: Bearer YOUR_API_KEY"Events come back oldest-first with payloads byte-identical to what the webhook carried, so you can feed them straight through the same handler — no separate reconciliation code path, no reconstructing history by diffing delivery state.
Two things to keep in mind:
- Stay idempotent on
eventId. An event can appear in this list and arrive by webhook, because an in-flight retry may succeed after you query. lastDeliveryStatustells you whether OnPoint is still retrying (pending), gave up (failed), or had nowhere to send it (no_endpoint_configured) — the last of which usually means the endpoint was never registered rather than that it broke.
If you would rather rebuild from state than replay events, GET /deliveries?from= still works: each DeliveryResponse carries status and statusHistory. Prefer the events API — it tells you precisely what you missed instead of making you infer it.