← Files Ship24 Tracking APIARCHIVED FILE

skills/ship24-webhooks/references/receiver-checklist.md

5.42 KB · Oct 3, 2026 · 06:35 UTC

↓ Download file

# Webhook receiver checklist and snippets

Sources: [Webhooks overview](https://docs.ship24.com/webhooks/overview), [Specification](https://docs.ship24.com/webhooks/specification),
[Authentication](https://docs.ship24.com/webhooks/authentication), [Sending behavior](https://docs.ship24.com/webhooks/delivery).
The 15-second timeout is verified in Ship24's implementation (see the plugin's CONTRIBUTING).

## Checklist

- [ ] Webhook URL set in the dashboard (Integrations → Webhooks); a separate URL for Proof of Delivery if the add-on is active.
- [ ] Endpoint accepts `POST`, is public, HTTPS.
- [ ] `Authorization: Bearer <webhook secret>` compared in constant time; wrong or missing secret → `401`.
- [ ] Any `2xx` returned before heavy work; the whole request completes well under 15 seconds.
- [ ] Payload persisted or enqueued, processed by a worker.
- [ ] Deduplication on `metadata.messageId` and `events[].eventId` (retries and the resend endpoint replay messages).
- [ ] Shipment matched on `tracker.trackerId` or `tracker.clientTrackerId`, never on the tracking number alone.
- [ ] Business rules applied only when the event is newer than the latest stored one (`occurrenceDatetime`, then `order` for equal or date-only values).
- [ ] Add-on fields read only when present.
- [ ] PoD `content.downloadUrl` fetched within 7 days.
- [ ] Tested with `SHIP24_SAMPLE_*` trackers and with `POST /trackers/{trackerId}/webhook-events/resend`.

The snippets below show the receiving half only. `app`, `express`, `queue`, `db` and `seen` are placeholders for
the app's Express instance, queue, storage and deduplication store. The official Node SDK has no webhook
verification or parsing helper; it does export the `Tracking` and `WebhookMetadata` payload types.

## Express (Node.js)

```javascript
import crypto from 'node:crypto';

const secret = Buffer.from(process.env.SHIP24_WEBHOOK_SECRET);

function secretMatches(header = '') {
  const [scheme, token = ''] = header.split(' ');
  const candidate = Buffer.from(token);
  return scheme === 'Bearer' && candidate.length === secret.length && crypto.timingSafeEqual(candidate, secret);
}

app.post('/webhooks/ship24', express.json({ limit: '1mb' }), async (req, res) => {
  if (!secretMatches(req.headers.authorization)) return res.sendStatus(401);
  await queue.enqueue('ship24-webhook', req.body);
  res.status(200).json({ ok: true });
});
```

Worker:

```javascript
async function processShip24Webhook(payload) {
  for (const tracking of payload.trackings) {
    const { metadata, tracker, shipment, events = [] } = tracking;
    if (await seen.has(metadata.messageId)) continue;
    const record = await db.shipments.findByTrackerId(tracker.trackerId);
    if (record) {
      if (metadata.topic === 'tracking/pod') {
        await db.shipments.update(record.id, { proofOfDelivery: tracking.data });
      }
      for (const event of events) {
        if (await seen.has(event.eventId)) continue;
        if (isNewer(event, record.lastEvent)) {
          await db.shipments.update(record.id, { status: shipment.statusMilestone, lastEvent: event });
        }
        await seen.add(event.eventId);
      }
    }
    await seen.add(metadata.messageId);
  }
}
```

`isNewer` compares `occurrenceDatetime` values parsed as dates when both carry a time, and falls back to the
`order` field (which may be `null`) when either is a bare date or the datetimes are equal. Proof-of-delivery
messages (`metadata.topic` = `tracking/pod`) carry `data` instead of `events` and `shipment`.

## FastAPI (Python)

```python
import os
import secrets

from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request

app = FastAPI()
SECRET = os.environ["SHIP24_WEBHOOK_SECRET"]


@app.post("/webhooks/ship24")
async def ship24_webhook(request: Request, tasks: BackgroundTasks, authorization: str = Header("")):
    scheme, _, token = authorization.partition(" ")
    if scheme != "Bearer" or not secrets.compare_digest(token, SECRET):
        raise HTTPException(status_code=401)
    payload = await request.json()
    tasks.add_task(process_ship24_webhook, payload)
    return {"ok": True}
```

`process_ship24_webhook` follows the same steps as the Node worker: skip seen `messageId`, match on
`tracker["trackerId"]`, skip seen `eventId`, compare `occurrenceDatetime` and `order`, then update.

## Rails (Ruby)

```ruby
# config/routes.rb: post "webhooks/ship24", to: "ship24_webhooks#create"
class Ship24WebhooksController < ActionController::API
  def create
    return head :unauthorized unless secret_matches?(request.authorization)

    Ship24WebhookJob.perform_later(JSON.parse(request.raw_post))
    render json: { ok: true }
  end

  private

  def secret_matches?(header)
    scheme, token = header.to_s.split(" ", 2)
    scheme == "Bearer" && ActiveSupport::SecurityUtils.secure_compare(token.to_s, ENV.fetch("SHIP24_WEBHOOK_SECRET"))
  end
end
```

`Ship24WebhookJob#perform(payload)` iterates `payload["trackings"]` with the same deduplication and ordering
rules.

## Production notes

- Keep deduplication entries for at least a week; a retry can arrive about six days after the first attempt
  and the resend endpoint replays a tracker's full history.
- Log the raw payload before processing; the webhook history download
  (`GET /trackers/{trackerId}/webhook-history/download`) shows Ship24's view of every push for comparison.
- If the endpoint filters by source IP, allow `54.161.7.2` (the documented outgoing IP) and remember that the
  dashboard test button does not use it.

SHA-256: 101cb87b11a18ffa3165a7f6629f3fd5c9d85a5318d84a9aa87bba17e8af6068