Outbound webhooks

Outbound Webhooks — Get Plus → Your endpoint

We POST every canonical tracking event to a URL you control with an HMAC SHA-256 signature so your downstream systems (OMS, warehouse management, ticketing, custom notification engines) can react in real time without polling our API.


Contents

  1. Set up in 5 minutes
  2. Headers and envelope
  3. Event catalog
  4. Verifying the signature
  5. Retries and idempotency
  6. Secret rotation
  7. Troubleshooting and FAQ

1. Set up in 5 minutes

  1. In the merchant dashboard, go to Tracking → Settings → Integration. Scroll to the Webhook card.
  2. Save your destination URL. Must be https:// — we reject plaintext HTTP.
  3. Click Generate secret. The value is shown once — copy it into your receiver's environment variable immediately.
  4. Click Send test delivery. This fires a synthetic event through the same code path as a real one. You should see a success confirmation inline.
  5. Toggle Enable. Every canonical tracking event now POSTs to your URL.

Pre-flight checklist for your receiver

  • Accept POST application/json at the configured URL.
  • Read the raw request body bytes (not a re-serialised JSON) so HMAC verification doesn't break on whitespace.
  • Verify X-PPLUS-Signature using HMAC SHA-256 with constant-time compare.
  • Respond with a 2xx within 10 seconds. Anything else triggers a retry.
  • Be idempotent on delivery_id — you may receive the same delivery twice under retry conditions.

2. Headers and envelope

Headers

HeaderExampleNotes
Content-Typeapplication/jsonAlways JSON.
User-AgentGet Plus-Webhook/1.0Identifies us in your logs.
X-PPLUS-Eventtracking.shippedEvent name for routing.
X-PPLUS-Delivery-Idwd_64f6c2a17b3e...Idempotency key.
X-PPLUS-Signaturesha256=ab12...HMAC of the raw body.

Envelope

{
  "delivery_id": "wd_64f6c2a17b3e2c001e3a9b22",
  "event": "tracking.shipped",
  "occurred_at": "2026-05-08T12:34:56.123Z",
  "store_id": "<your-store-id>",
  "data": {
    "shipment_id": "shp_abc123",
    "order_id": "ord_xyz789",
    "tracking_number": "1Z999AA10123456784",
    "carrier_code": "ups",
    "carrier_name": "UPS",
    "canonical_event": "shipped",
    "occurred_at": "2026-05-08T12:34:56.000Z",
    "estimated_delivery": "2026-05-11T20:00:00.000Z",
    "destination": {
      "country": "US",
      "postal_code": "94107",
      "city": "San Francisco"
    },
    "raw_carrier_status": "DEPARTED FACILITY",
    "store_id": "<your-store-id>"
  }
}

Field semantics

  • delivery_id — unique per delivery. Your idempotency key.
  • event — fully-qualified event name. Strip tracking. to get the canonical event.
  • occurred_at (envelope) — when we processed the event.
  • data.occurred_at — when the carrier scan occurred.
  • data.canonical_event — one of: shipment_created, shipped, out_for_delivery, delivered, exception. Switch business logic on this.
  • data.raw_carrier_status — original carrier string (for display only, don't switch on it).

3. Event catalog

EventWhen sentTypical merchant use
tracking.shipment_createdLabel accepted / shipment registeredMark order "shipped", send notification
tracking.shippedFirst in-network scanPromote to "in transit"
tracking.out_for_deliveryOn the truckSame-day delivery comms
tracking.deliveredCarrier confirms deliveryClose order, trigger review request
tracking.exceptionProblem reportedOpen support ticket

If you receive an event you don't recognise, ignore it and respond 2xx — don't 4xx unknown events.


4. Verifying the signature

The signature is HMAC SHA-256 of the raw body bytes, hex-encoded, prefixed with sha256=.

Rules

  1. Verify on raw bytes — not re-serialised JSON.
  2. Compare in constant time — never ==.

Node.js (Express)

const crypto = require('crypto');
const SECRET = process.env.PPLUS_WEBHOOK_SECRET;

app.post(
  '/webhooks/pplus-tracking',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.header('X-PPLUS-Signature') || '';
    if (!sig.startsWith('sha256=')) return res.status(401).end();

    const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
    const given = sig.slice('sha256='.length);
    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(given, 'hex');

    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString('utf8'));
    res.status(200).end();
    enqueueForProcessing(event);
  }
);

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, abort

SECRET = os.environ['PPLUS_WEBHOOK_SECRET'].encode()

@app.post('/webhooks/pplus-tracking')
def receive():
    sig = request.headers.get('X-PPLUS-Signature', '')
    if not sig.startswith('sha256='):
        abort(401)
    raw = request.get_data()
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig[len('sha256='):]):
        abort(401)
    return ('', 200)

PHP

$secret = getenv('PPLUS_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PPLUS_SIGNATURE'] ?? '';
if (strpos($header, 'sha256=') !== 0) { http_response_code(401); exit; }
$expected = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, substr($header, 7))) { http_response_code(401); exit; }
http_response_code(200);

5. Retries and idempotency

Retry schedule

AttemptDelayCumulative
1immediate0
21 min1 m
35 min6 m
430 min36 m
52 hours~2.5 h
612 hours~14.5 h

After attempt 6 the delivery is dead-lettered. The row stays in your dashboard for investigation.

What counts as failure

  • Non-2xx HTTP status
  • Connection refused / DNS failure / TLS error
  • No response within 10 seconds

Your side: idempotency

Store the delivery_id and skip duplicates:

INSERT INTO pplus_processed_deliveries(delivery_id) VALUES ($1)
ON CONFLICT (delivery_id) DO NOTHING RETURNING delivery_id;

Out-of-order events

Events may arrive out of order. Compare data.occurred_at and only advance state forward. exception can fire at any point.


6. Secret rotation

Rotate on a schedule (every 90 days) or immediately if compromised.

Procedure

  1. In the dashboard, click Regenerate on the Webhook card.
  2. For ~30 minutes, accept either the old OR new secret in your receiver:
function verify(rawBody, sigHeader, secrets) {
  const given = sigHeader.replace(/^sha256=/, '');
  return secrets.some((secret) => {
    const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(given, 'hex');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}
  1. After the rollover window, drop the old secret.

7. Troubleshooting and FAQ

Dashboard first

Check the Webhook card → Recent deliveries strip for success/failure at a glance. Click View all for the last 50 attempts with copyable IDs and body hashes.

Common errors

SymptomLikely causeFix
HTTP 401/403Signature mismatchVerify on raw bytes per section 4
HTTP 404URL changedUpdate the URL in the Webhook card
HTTP 5xxReceiver crashedCheck your logs for the delivery_id
TimeoutHandler too slowReturn 2xx fast, process async
ECONNREFUSEDEndpoint unreachableEnsure public internet access
TLS errorInvalid certificateUse a CA-issued cert

FAQ

Q: Multiple events per POST?
A: No. One event = one POST.

Q: Rate limits?
A: No per-merchant caps today. Slow endpoints cause queueing, not drops.

Q: Delivery retention?
A: 30 days in the audit log, then purged.

Q: Static IP range?
A: Not currently. Allowlist by signature, not source IP.

Q: Don't see the Webhook card?
A: Feature is rolling out. Contact your Get Plus account manager.


Did this page help you?