Outbound webhooks

ProtectPlus can POST every tracking event to a merchant-controlled URL with an HMAC signature so that downstream systems (OMS, warehouse management, ticketing, custom notification engines) can react in real time without polling our API.

This is the inverse of the inbound (Path B / OMS push) integration documented in the Integration sub-tab: there, the merchant POSTs shipments to P+; here, P+ POSTs canonical tracking events back to the merchant.

Configuring

In the merchant dashboard:

  1. Tracking → Settings → Integration tab
  2. Scroll to Forward updates to your endpoint
  3. Save your https URL (http is rejected — we sign over the wire)
  4. Click Generate secret and copy the value (shown exactly once)
  5. Click Send test delivery to smoke-test your receiver
  6. Toggle Enable to start receiving live events

Test deliveries fire even when the toggle is off — they're meant for pre-flight validation before going live.

Payload shape

P+ POSTs application/json with this envelope:

{
  "event_type": "delivered",
  "delivery_id": "65f2c1ab2c4d5e6f7a8b9c01",
  "delivered_at": "2026-05-12T10:34:01.234Z",
  "payload_version": 1,
  "data": {
    "shipment_id": "65f2c0...",
    "store_id": "65f2bf...",
    "order_id": "65f2bf...",
    "order_number": "1024",
    "customer_email": "[email protected]",
    "tracking_number": "1Z999AA10123456784",
    "carrier": "ups",
    "tracking_url": "https://www.ups.com/track?...",
    "canonical_event": "delivered",
    "canonical_status": "DELIVERED",
    "canonical_substatus": null,
    "canonical_label": "Delivered",
    "timeline_step": "delivered",
    "shipped_at": "2026-05-10T08:00:00.000Z",
    "delivered_at": "2026-05-12T10:33:55.000Z",
    "occurred_at": "2026-05-12T10:34:00.000Z",
    "tracking_status_details": { "...": "..." }
  }
}

Event types

The event_type field on the envelope is one of:

EventMeaning
shipment_createdLabel printed but not yet scanned (PRE_TRANSIT)
shippedFirst in-transit scan
out_for_deliveryOn the truck for final delivery
deliveredConfirmed delivery scan
exceptionDelivery failure / address issue / returned to sender
testSynthetic event from the Send test delivery button

Each canonical event fires at most once per shipment — the dispatcher de-duplicates on the same internal merchant_event_history ledger that drives P+'s own notification engine.

HMAC verification

Every request carries an X-PPLUS-Signature header in the form sha256=<hex> computed as HMAC-SHA-256 over the raw request body using your secret.

Node.js

const crypto = require('crypto');

function verify(rawBody, signatureHeader, secret) {
  const [scheme, hex] = (signatureHeader || '').split('=');
  if (scheme !== 'sha256' || !hex) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');
  // Constant-time compare to avoid timing attacks
  const a = Buffer.from(hex, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Python

import hashlib, hmac

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or '=' not in signature_header:
        return False
    scheme, hex_sig = signature_header.split('=', 1)
    if scheme != 'sha256':
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(hex_sig, expected)

Important — your framework MUST give you the raw request body bytes, not a re-serialised version. Express needs the verify callback on body-parser (see our own global.packages.js for an example); FastAPI needs await request.body() before parsing JSON.

Retry policy

If your endpoint returns a non-2xx status or the request times out (10s), the delivery is retried with exponential backoff:

AttemptDelay since previous
1(immediate)
21 minute
35 minutes
430 minutes
52 hours
612 hours

After the 6th attempt the delivery is marked failed (dead-letter) and will not be retried automatically. The merchant dashboard surfaces dead-lettered deliveries in the Recent deliveries strip with a red Failed pill.

Idempotency on your side

The delivery_id field on every payload is unique per delivery row in our database. If the same delivery_id arrives twice (e.g. our network hiccup made us think your 2xx response failed), treat it as the same event and respond with another 2xx — your processing logic should be idempotent on delivery_id.

The data.shipment_id + data.canonical_event pair is also a stable deduplication key per shipment lifecycle event.

Headers reference

HeaderDescription
Content-TypeAlways application/json
X-PPLUS-Signaturesha256=<hex> HMAC over raw body
X-PPLUS-EventSame value as event_type in the body
X-PPLUS-Delivery-IdSame value as delivery_id in the body
User-AgentProtectPlus-Webhooks/1.0

The signature, event, and delivery_id are duplicated in headers so a thin forwarder / log aggregator can route on them without parsing JSON.

Rotating your secret

Hit Regenerate in the dashboard. The previous secret is invalidated immediately — any delivery in flight signed with the old value will fail verification on your side until you cycle to the new value. Coordinate the rotation with your deployment to minimise the missed-event window.