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
- Set up in 5 minutes
- Headers and envelope
- Event catalog
- Verifying the signature
- Retries and idempotency
- Secret rotation
- Troubleshooting and FAQ
1. Set up in 5 minutes
- In the merchant dashboard, go to Tracking → Settings → Integration. Scroll to the Webhook card.
- Save your destination URL. Must be
https://— we reject plaintext HTTP. - Click Generate secret. The value is shown once — copy it into your receiver's environment variable immediately.
- 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.
- Toggle Enable. Every canonical tracking event now POSTs to your URL.
Pre-flight checklist for your receiver
- Accept
POST application/jsonat 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-Signatureusing 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
| Header | Example | Notes |
|---|---|---|
Content-Type | application/json | Always JSON. |
User-Agent | Get Plus-Webhook/1.0 | Identifies us in your logs. |
X-PPLUS-Event | tracking.shipped | Event name for routing. |
X-PPLUS-Delivery-Id | wd_64f6c2a17b3e... | Idempotency key. |
X-PPLUS-Signature | sha256=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. Striptracking.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
| Event | When sent | Typical merchant use |
|---|---|---|
tracking.shipment_created | Label accepted / shipment registered | Mark order "shipped", send notification |
tracking.shipped | First in-network scan | Promote to "in transit" |
tracking.out_for_delivery | On the truck | Same-day delivery comms |
tracking.delivered | Carrier confirms delivery | Close order, trigger review request |
tracking.exception | Problem reported | Open 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
- Verify on raw bytes — not re-serialised JSON.
- 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
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 1 min | 1 m |
| 3 | 5 min | 6 m |
| 4 | 30 min | 36 m |
| 5 | 2 hours | ~2.5 h |
| 6 | 12 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
- In the dashboard, click Regenerate on the Webhook card.
- 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);
});
}- 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
| Symptom | Likely cause | Fix |
|---|---|---|
| HTTP 401/403 | Signature mismatch | Verify on raw bytes per section 4 |
| HTTP 404 | URL changed | Update the URL in the Webhook card |
| HTTP 5xx | Receiver crashed | Check your logs for the delivery_id |
| Timeout | Handler too slow | Return 2xx fast, process async |
| ECONNREFUSED | Endpoint unreachable | Ensure public internet access |
| TLS error | Invalid certificate | Use 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.
Updated about 1 month ago