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
/v1/webhooksRequires webhooks:manage. The URL must be https.
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.
secret, once. You need it to verify us, and we cannot show it again. Store it the way you store an API key.Events
| Event | Fires when |
|---|---|
parcel.created | You booked a parcel. |
parcel.awaiting_dropoff | Waiting for you to drop it off. |
parcel.in_transit | Collected and moving. |
parcel.at_pickup_station | Arrived; your customer has their PIN. |
parcel.delivered | Collected by your customer. |
parcel.returned | Not collected in time. |
parcel.cancelled | Cancelled. |
cod.settled | Cash collected and credited to your wallet. |
What we post
{
"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
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']);
}// 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:
| Attempt | After |
|---|---|
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
After six failures the delivery is abandoned, and three abandoned deliveries mark your endpoint as failing so we can tell you.
id and ignore ids you have already handled — otherwise a retried cod.settled could be counted twice in your books.Debugging your endpoint
/v1/webhooks/:id/deliveriesEvery 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
/v1/webhooks/:idNext: errors and limits.
