# 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

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

```bash
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"]
  }'
```
```js
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();
```
```python
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

| 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

Each delivery is a JSON object. The `data` fields depend on the event type and use camelCase names.

```json
{
  "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

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.

```js

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);
});
```
```python
import hashlib
import hmac
import 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
```
```bash
# Check a saved delivery by hand. BODY is the exact request body, T the t= value from the header.
SECRET="your endpoint secret"
T=1777834802
BODY='{"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.
```
**Caution:** Do not parse and re-serialize the JSON before you verify. Any change to whitespace or key order changes the signature. Use the bytes exactly as they arrived.

## 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

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

- 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.