Webhooks
Webhooks send an HTTP POST to your server when something happens, so you do not have to poll. You can manage endpoints in Settings, Developers, or through the API with the webhooks:manage permission.
Create an endpoint
Section titled “Create an endpoint”POST /webhooks takes the URL, an optional description and the events you want. Use "*" to receive every event.
curl https://shipjay.com/api/v1/webhooks \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/shipjay", "description": "Order system", "event_types": ["shipment.purchased", "tracking.updated"] }'const res = await fetch("https://shipjay.com/api/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.SHIPJAY_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://hooks.example.com/shipjay", description: "Order system", event_types: ["shipment.purchased", "tracking.updated"], }),});const { id, secret } = await res.json();res = requests.post( "https://shipjay.com/api/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['SHIPJAY_KEY']}"}, json={ "url": "https://hooks.example.com/shipjay", "description": "Order system", "event_types": ["shipment.purchased", "tracking.updated"], }, timeout=30,)endpoint = res.json() # {"id": "...", "secret": "..."}The response is 201 with the endpoint id and its signing secret. The secret is shown once. Store it where your server can read it.
The URL must use https, must not contain a user name or password, and must resolve to a public address. Addresses on private networks are refused when you save the endpoint and again before every delivery.
Events
Section titled “Events”| Event | When it fires | Fields in data |
|---|---|---|
shipment.purchased | A label was bought. | shipmentId, trackingCode, carrier, service, chargedCents, currency |
shipment.refund_requested | A refund request was sent to the carrier. | shipmentId, status |
shipment.refunded | The carrier approved the refund and the wallet was credited. | shipmentId, creditCents, currency |
shipment.refund_rejected | The carrier rejected the refund. | shipmentId, status |
tracking.updated | The tracking status of a shipment changed. | shipmentId, status, previousStatus, trackingCode |
return.requested | A customer asked to return a shipment. | returnId, rma, shipmentId, reason |
return.approved | A return was approved and its prepaid label was bought. | returnId, rma |
return.rejected | A return was rejected. | returnId, rma |
return.received | A returned parcel was received. | returnId, rma |
adjustment.applied | A carrier adjustment was charged to the wallet. | adjustmentId, shipmentId, amountCents, currency, reason |
batch.completed | A batch of labels finished processing. | batchId, bought, failed, state |
Every event is also available from GET /events for 30 days, oldest first, so you can catch up after downtime. Pass the id of the last event you processed as after.
Payload
Section titled “Payload”Each delivery is a JSON object. The data fields depend on the event type and use camelCase names.
{ "id": "6d1f0a8c-92b4-4e57-b3a6-0c7e5d2f9a14", "type": "shipment.purchased", "created_at": "2026-05-03T18:40:02Z", "data": { "shipmentId": "2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20", "trackingCode": "9400100000000000000000", "carrier": "USPS", "service": "Ground Advantage", "chargedCents": 864, "currency": "USD" }}Request headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
ShipJay-Webhooks/1 |
ShipJay-Event-Id |
The event id, the same as id in the body. |
ShipJay-Delivery-Id |
The id of this delivery. It stays the same across retries. |
ShipJay-Signature |
t=<unix seconds>,v1=<hex signature> |
Verify the signature
Section titled “Verify the signature”The signature is an HMAC-SHA256, in hex, of the string <t>.<raw body> computed with your endpoint secret. Check it on the raw bytes of the body, before you parse the JSON. Reject the request when the signature does not match, and when t is more than five minutes away from your clock. Compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyShip Jay(secret, header, rawBody, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const given = Buffer.from(parts.v1); const wanted = Buffer.from(expected); return given.length === wanted.length && timingSafeEqual(given, wanted);}
// Express: use express.raw({ type: "application/json" }) so req.body is the raw Buffer.app.post("/shipjay", express.raw({ type: "application/json" }), (req, res) => { const ok = verifyShip Jay(process.env.SHIPJAY_WEBHOOK_SECRET, req.get("ShipJay-Signature") ?? "", req.body.toString("utf8")); if (!ok) return res.sendStatus(400); const event = JSON.parse(req.body.toString("utf8")); // Store event.id, then handle the event. res.sendStatus(200);});import hashlibimport hmacimport time
def verify_shipjay(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts["t"]) given = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - t) > tolerance: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(given, expected)
# Flask:# @app.post("/shipjay")# def shipjay():# raw = request.get_data()# if not verify_shipjay(SECRET, request.headers.get("ShipJay-Signature", ""), raw):# return "", 400# event = json.loads(raw)# return "", 200# Check a saved delivery by hand. BODY is the exact request body, T the t= value from the header.SECRET="your endpoint secret"T=1777834802BODY='{"id":"6d1f0a8c-92b4-4e57-b3a6-0c7e5d2f9a14","type":"shipment.purchased","created_at":"2026-05-03T18:40:02Z","data":{}}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex# Compare the printed value with v1= in the ShipJay-Signature header.Respond quickly
Section titled “Respond quickly”Answer with any 2xx status within 10 seconds. Do the real work after you reply, for example by putting the event on a queue. Anything else counts as a failed attempt: a non 2xx status, a timeout, a connection error, and also a redirect, because Ship Jay does not follow redirects.
Retries
Section titled “Retries”A failed delivery is tried again with a growing delay: 30 seconds after the first failure, then 1 minute, 2, 4, 8, 16, 32 and 64 minutes, and 128 minutes before the tenth and last attempt. The delay is capped at 6 hours. That is ten attempts over about four hours. A delivery that fails all ten attempts is marked failed, and you can retry it from Settings, Developers.
If 20 deliveries in a row fail completely, Ship Jay disables the endpoint and shows the reason in the app. Fix the problem and enable the endpoint again. Events that happened while it was disabled are not delivered, but you can still read them from GET /events for 30 days.
Replay protection and duplicates
Section titled “Replay protection and duplicates”- The five minute window on
tstops someone from replaying an old capture later. Ship Jay signs each attempt with the time of that attempt, so a retry is not rejected for being old. - Delivery is at least once. You may receive the same event twice, for example when your server answered after the timeout. Store the event
idand ignore ones you have already handled. - Events can arrive out of order. Use
created_at, or thepreviousStatusfield ontracking.updated, when order matters. - Keep the endpoint secret private. If it leaks, delete the endpoint and create a new one.
© 2026 Ship Jay. All rights reserved. Created & Developed by WCWS Internet
