Skip to content

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.

POST /webhooks takes the URL, an optional description and the events you want. Use "*" to receive every event.

Terminal window
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"]
}'

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.

EventWhen it firesFields in data
shipment.purchasedA label was bought.shipmentId, trackingCode, carrier, service, chargedCents, currency
shipment.refund_requestedA refund request was sent to the carrier.shipmentId, status
shipment.refundedThe carrier approved the refund and the wallet was credited.shipmentId, creditCents, currency
shipment.refund_rejectedThe carrier rejected the refund.shipmentId, status
tracking.updatedThe tracking status of a shipment changed.shipmentId, status, previousStatus, trackingCode
return.requestedA customer asked to return a shipment.returnId, rma, shipmentId, reason
return.approvedA return was approved and its prepaid label was bought.returnId, rma
return.rejectedA return was rejected.returnId, rma
return.receivedA returned parcel was received.returnId, rma
adjustment.appliedA carrier adjustment was charged to the wallet.adjustmentId, shipmentId, amountCents, currency, reason
batch.completedA 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.

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>

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);
});

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.

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.

  • The five minute window on t stops 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 id and ignore ones you have already handled.
  • Events can arrive out of order. Use created_at, or the previousStatus field on tracking.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