Skip to content

Webhooks

OnPoint delivers real-time notifications to your HTTPS endpoint as deliveries progress. Webhooks remove the need to poll for status changes.

Setup

  1. Provide an HTTPS URL with a valid TLS certificate. Plain HTTP is rejected.
  2. Configure the endpoint in the OnPoint dashboard (Settings → Webhooks).
  3. 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.

HeaderDescription
webhook-idUnique event identifier. Same value as eventId in the body. Use as your dedupe key.
webhook-timestampUnix timestamp in seconds at which the event was sent
webhook-signatureSpace-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": { }
}
FieldDescription
eventIdDedupe key. Process each eventId at most once.
eventTypeNamespaced event identifier (see catalog below)
occurredAtWhen the event happened (UTC)
previousStatusPrior delivery status, when applicable
deliveryFull 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:

eventTypeDelivery statusDescription
delivery.acceptedacceptedDelivery created and dispatch started
delivery.dispatch_faileddispatch_failedNo driver could be assigned
delivery.scheduledscheduledScheduled for a future window
delivery.reassigningreassigningBeing re-dispatched — not a cancellation
delivery.driver_assigneddriver_assignedDriver assigned
delivery.driver_enroute_to_pickupdriver_enroute_to_pickupDriver heading to pickup
delivery.driver_at_pickupdriver_at_pickupDriver arrived at pickup
delivery.pickup_completepickup_completePickup completed
delivery.driver_enroute_to_dropoffdriver_enroute_to_dropoffDriver heading to customer
delivery.driver_at_dropoffdriver_at_dropoffDriver arrived at customer
delivery.delivereddeliveredSuccessfully delivered
delivery.failedfailedDelivery failed
delivery.return_in_progressreturn_in_progressReturn to store in progress
delivery.returnedreturnedReturned to store
delivery.cancelledcancelledCancelled
delivery.expiredexpiredExpired without completion

Informational events (no status change)

These may fire multiple times during a delivery:

eventTypeDescription
delivery.driver_location_updatedDriver GPS updated (~every 1–2 min when available)
delivery.eta_updatedPickup or dropoff ETA changed
delivery.proof_of_delivery_addedPhoto, signature, barcode, or pincode captured

Driver event catalog

Available when fleet management is enabled for your account:

eventTypeDescription
driver.createdNew driver added to roster
driver.updatedDriver profile changed
driver.deletedDriver removed
driver.duty_changedOn-duty / off-duty status changed

Installation event catalog

Sent when you deliver to military installations.

eventTypeDescription
installation.access_policy_changedAccess 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

  1. Return 200 quickly — acknowledge receipt, then process asynchronously. Verifying the signature is the only work that belongs in the request handler.
  2. Dedupe on eventId — delivery is at-least-once, so the same event can arrive more than once.
  3. Do not assume ordering. Retries mean a redelivered driver_assigned can land after delivered. Order by occurredAt, not arrival time, and ignore backward transitions: if you have already recorded pickup_complete, drop a late driver_at_pickup.
  4. Treat the embedded delivery object 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.
  5. delivery.reassigning is not terminal — expect a subsequent delivery.driver_assigned.
  6. Do not treat informational events as terminaldriver_location_updated does not mean delivered.
  7. Handle unknown eventType values 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.
  8. Check livemode. Sandbox events — including those from POST /deliveries/{id}/simulate — carry livemode: false. Asserting on it means a misconfigured sandbox endpoint pointed at production can never quietly move real orders.
  9. Branch on statusReasonCode, not statusReason. The prose changes without notice. In particular, do not lump the gate-access codes in with recipient_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:

AttemptDelay before this attempt
1Immediately
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 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.
  • lastDeliveryStatus tells 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.