Sendway

Webhooks

Register a URL and we post to it the moment a parcel moves. Every request is signed, so you can tell it came from us.

Register an endpoint

POST/v1/webhooks

Requires webhooks:manage. The URL must be https.

bash
curl https://api.shopinn.co.ke/api/sendway/v1/webhooks \
  -H "Authorization: Bearer sw_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://myshop.co.ke/hooks/sendway",
    "events": ["parcel.delivered", "cod.settled"],
    "description": "Production"
  }'

Leave events out to receive everything.

The response contains a secret, once. You need it to verify us, and we cannot show it again. Store it the way you store an API key.

Events

EventFires when
parcel.createdYou booked a parcel.
parcel.awaiting_dropoffWaiting for you to drop it off.
parcel.in_transitCollected and moving.
parcel.at_pickup_stationArrived; your customer has their PIN.
parcel.deliveredCollected by your customer.
parcel.returnedNot collected in time.
parcel.cancelledCancelled.
cod.settledCash collected and credited to your wallet.

What we post

json
{
  "id": "evt_e8d29b286126368b2edffe2b",
  "object": "event",
  "type": "parcel.delivered",
  "created": 1789553339,
  "data": {
    "parcel": {
      "id": "9f3a1c72-...",
      "parcel_number": "PCL-A1B2C3",
      "tracking_number": "SHP-8DD1D9",
      "status": "DELIVERED",
      "payment_status": "PAID",
      "total_fee": 350,
      "currency": "KES",
      "description": "Two dresses",
      "package_size": "S",
      "recipient_name": "Achieng Otieno",
      "pickup_run_id": "cbcb7d07-..."
    }
  }
}

Headers: SendWay-Signature, SendWay-Event, SendWay-Delivery.

Verifying the signature

The header is t=<unix seconds>,v1=<hmac>. The HMAC is SHA-256 over ${t}.${raw body} using your endpoint secret. Verify against the raw body — parsing and re-serialising the JSON first changes the bytes and the signature will never match.

php
// PHP
function sendway_verify(string $secret, string $body, string $header): bool {
    $parts = [];
    foreach (explode(',', $header) as $chunk) {
        [$k, $v] = array_pad(explode('=', trim($chunk), 2), 2, null);
        if ($v !== null) $parts[$k] = $v;
    }
    if (empty($parts['t']) || empty($parts['v1'])) return false;

    // Reject anything older than five minutes so a captured call
    // cannot be replayed at you later.
    if (abs(time() - (int) $parts['t']) > 300) return false;

    $expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret);
    return hash_equals($expected, $parts['v1']);
}
javascript
// Node
const crypto = require('crypto')

function verify(secret, rawBody, header, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=').map((s) => s.trim()))
  )
  if (!parts.t || !parts.v1) return false
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > toleranceSec) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex')

  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(parts.v1, 'hex')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

The timestamp is inside the signed material, so someone who captures a call cannot replay it with a fresh timestamp without also forging the HMAC.

Replies, retries and duplicates

Reply 2xx as soon as you have stored the event. Do the slow work afterwards — we time out after 10 seconds and treat that as a failure.

Anything else is retried on this ladder:

AttemptAfter
210 seconds
31 minute
410 minutes
51 hour
66 hours

After six failures the delivery is abandoned, and three abandoned deliveries mark your endpoint as failing so we can tell you.

Delivery is at-least-once, so expect duplicates. A retry after a reply we never received sends the same event again. Record id and ignore ids you have already handled — otherwise a retried cod.settled could be counted twice in your books.

Debugging your endpoint

GET/v1/webhooks/:id/deliveries

Every attempt we made, with the status code and the first 500 characters of your response. Check here before asking us why an event never arrived — usually it did, and something on your side returned a 500.

Remove an endpoint

DELETE/v1/webhooks/:id

Next: errors and limits.