Quick start
Five minutes with a test key. Sign up at developer.swiftgogh.com and you get one straight away. Test mode books pretend deliveries: no riders, no money. A simulator moves each one through every status and sends you real webhooks.
curl https://api.swiftgogh.com/api/business/v1/ping \ -H "Authorization: Bearer sk_test_..."
# 2 · Book a delivery
curl -X POST https://api.swiftgogh.com/api/business/v1/deliveries \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{
"external_reference": "1001",
"pickup": { "latitude": 5.5570, "longitude": -0.1830,
"address": "12 Oxford St, Osu",
"contact": { "name": "Ama Shoes", "phone": "0241234567" } },
"dropoff": { "latitude": 5.6395, "longitude": -0.1530,
"address": "East Legon", "landmark": "Near A&C Mall",
"contact": { "name": "Kofi Mensah", "phone": "0551234567" },
"instructions": "Call on arrival" },
"package": { "description": "Shoes (1 box)", "size": "small", "value": 250 }
}'
// 2 · Book a delivery (Node 18+, built-in fetch) const res = await fetch('https://api.swiftgogh.com/api/business/v1/deliveries', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SWIFTGO_KEY}`, 'Idempotency-Key': `order-${order.id}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ external_reference: String(order.id), pickup: { latitude: 5.5570, longitude: -0.1830, address: '12 Oxford St, Osu', contact: { name: 'Ama Shoes', phone: '0241234567' } }, dropoff: { latitude: 5.6395, longitude: -0.1530, address: 'East Legon', contact: { name: 'Kofi Mensah', phone: '0551234567' } }, package: { description: 'Shoes (1 box)', size: 'small', value: 250 }, }), }); const delivery = await res.json(); if (!res.ok) throw new Error(delivery.error.code); // e.g. OUT_OF_COVERAGE console.log(delivery.id, delivery.status, delivery.price.amount);
// 2 · Book a delivery (PHP + cURL) $ch = curl_init('https://api.swiftgogh.com/api/business/v1/deliveries'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SWIFTGO_KEY'), 'Idempotency-Key: order-' . $order['id'], 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'external_reference' => (string) $order['id'], 'pickup' => ['latitude' => 5.5570, 'longitude' => -0.1830, 'address' => '12 Oxford St, Osu', 'contact' => ['name' => 'Ama Shoes', 'phone' => '0241234567']], 'dropoff' => ['latitude' => 5.6395, 'longitude' => -0.1530, 'address' => 'East Legon', 'contact' => ['name' => 'Kofi Mensah', 'phone' => '0551234567']], 'package' => ['description' => 'Shoes (1 box)', 'size' => 'small', 'value' => 250], ]), ]); $delivery = json_decode(curl_exec($ch), true);
You get back a delivery with "status": "pending". In test mode it moves to rider_assigned, picked_up, arriving and delivered over about two minutes. To jump ahead, call POST /deliveries/{id}/simulate with {"status": "delivered"} or "failed".
Keys and authentication
Pretend deliveries for building and testing. Created for you when you sign up at developer.swiftgogh.com.
Real riders and real deliveries, billed to your account. Request live access in the developer portal; you can create live keys once SwiftGo approves it and sets your credit limit.
Send your key on every request as Authorization: Bearer sk_live_… (or X-Api-Key: sk_live_…). Test and live data are completely separate: a test key can't see live deliveries and the other way round.
- Every response has an
X-Request-Idheader and every error arequest_id. Include it when you contact SwiftGo. - Rate limit: 120 requests per minute per key. Over the limit you get
429 RATE_LIMITEDwith aRetry-Afterheader in seconds. - JSON in, JSON out. Times are ISO-8601 in UTC. Money is cedis (GHS) as a number with 2 decimals.
Endpoints
Idempotency-Key header.status, external_reference, created_after, created_before. Page with limit (up to 100) and starting_after.rider_assigned, picked_up, arriving, delivered or failed now.ping now (up to 5 per minute).delivery_id or type.IDs carry a prefix that tells you what they are: del_ deliveries, qt_ quotes, evt_ events.
Quotes
Optional. Get a price before you book, for example to show the delivery fee at your checkout.
{ "pickup": { "latitude": 5.5570, "longitude": -0.1830 },
"dropoff": { "latitude": 5.6395, "longitude": -0.1530 } }
{
"id": "qt_01jcx7k8f9m2r3t4v5w6x7y8z9",
"object": "quote",
"livemode": false,
"price": { "amount": 31.5, "delivery_fee": 26.5, "service_fee": 5.0, "currency": "GHS" },
"distance_km": 9.8,
"eta_minutes": 38,
"zone": { "name": "Accra Central" },
"expires_at": "2026-11-12T10:15:00Z",
"used": false
}
Pass the quote's id as quote_id when you book to be billed exactly that price. A quote works once, for the same mode, and only for (nearly) the same pickup and drop-off: within about 150 metres.
Booking a delivery
POST /deliveries with an Idempotency-Key header. Phone numbers can be in any common Ghana format (0241234567, +233241234567, 233 24 123 4567) and come back as +233….
| Field | Required | Notes |
|---|---|---|
pickup.latitude, pickup.longitude | yes | Where the rider collects. Ghana only. |
pickup.contact.name, .phone | yes | Who hands the package over. The rider can call them. |
dropoff.latitude, dropoff.longitude | yes | Where the rider delivers. |
dropoff.contact.name, .phone | yes | The recipient. They get an SMS with a live tracking link and the delivery code. |
package.description | yes | What's being delivered, e.g. "Shoes (1 box)". 200 characters max. |
*.address, *.landmark, *.ghana_post_gps, *.instructions | optional | Shown to the rider for pickup and drop-off. GhanaPostGPS like GA-123-4567. |
package.size | optional | small, medium, large or xlarge. |
package.weight_kg | optional | Up to 30 (motorbike delivery). |
package.value, quantity, fragile | optional | Declared value in GHS, 1 to 50 items, true/false. |
quote_id | optional | Bill the quoted price. |
external_reference | optional | Your order ID. You can search by it. |
merchant.external_id, .name, .phone | optional | Platforms: the seller on your platform. The rider and the recipient see the name. |
metadata | optional | Up to 20 of your own key/value pairs (keys up to 40 characters, values up to 500), returned unchanged. |
The delivery object
{
"id": "del_01jcx9p2q3r4s5t6v7w8x9y0z1",
"object": "delivery",
"livemode": true,
"status": "rider_assigned",
"external_reference": "1001",
"order_number": "SG-20261112-04512",
"price": { "amount": 31.5, "delivery_fee": 26.5, "service_fee": 5.0, "currency": "GHS" },
"distance_km": 9.8,
"pickup": { "latitude": 5.557, "longitude": -0.183, "address": "12 Oxford St, Osu",
"contact": { "name": "Ama Shoes", "phone": "+233241234567" } },
"dropoff": { "latitude": 5.6395, "longitude": -0.153, "address": "East Legon",
"contact": { "name": "Kofi Mensah", "phone": "+233551234567" } },
"package": { "description": "Shoes (1 box)", "size": "small", "value": 250, "quantity": 1, "fragile": false },
"rider": { "name": "Yaw M.", "phone": "+233...", "photo_url": "...",
"vehicle_type": "motorbike", "vehicle_plate": "GT 1234-24" },
"tracking_url": "https://swiftgogh.com/t/...",
"eta": { "dropoff_at": "2026-11-12T10:41:00Z" },
"proof_of_delivery": null,
"cancellation": null,
"timeline": [ { "status": "pending", "at": "2026-11-12T10:02:11Z" },
{ "status": "rider_assigned", "at": "2026-11-12T10:04:37Z" } ],
"metadata": { "platform_order": "1001" },
"created_at": "2026-11-12T10:02:11Z",
"updated_at": "2026-11-12T10:04:37Z"
}
rider.phoneis shown only while the delivery is in progress, so the seller can hand over the package.tracking_urlis a public live-tracking page for the recipient. It isnullin test mode and stops working a day after the delivery ends.proof_of_deliveryappears once delivered:otp_verified(the recipient gave the rider their code) andphoto_urlif the rider took a photo.
Statuses
| Status | What it means |
|---|---|
pending | Booked. SwiftGo is finding a rider. |
rider_assigned | A rider accepted and is heading to the pickup. Sent again if a different rider takes over. |
picked_up | The rider has the package. The recipient gets the tracking-link SMS. |
arriving | The rider is close. The recipient gets an SMS with the delivery code. |
delivered | Done. See proof_of_delivery. |
failed | The rider couldn't complete it, for example the recipient couldn't be reached. |
cancelled | Cancelled by you or by SwiftGo, for example no rider was available. See cancellation.cancelled_by. |
A status only moves forward, and delivered, failed and cancelled are final. One exception: if the rider drops the job before pickup, the delivery goes back to pending (event delivery.rider_unassigned, rider details removed) until another rider accepts.
Safe retries
Every POST /deliveries needs an Idempotency-Key header: any unique string, such as your order ID. If a request times out, send it again with the same key and the same body.
- If the first request worked, you get the same delivery back with
Idempotent-Replayed: true. A second delivery is never created. - The same key with a different body is refused with
422 IDEMPOTENCY_KEY_REUSED. - If the first request is still running, you get
409 IDEMPOTENCY_IN_PROGRESS. Retry after a second or two. - Keys are remembered for 24 hours.
POST /deliveries/{id}/cancelaccepts one too.
Cancelling and charges
| When | Can you cancel? | Charge |
|---|---|---|
pending, no rider yet | Yes | Free |
rider_assigned | Yes | Cancellation fee, 50% of the price by default |
picked_up or arriving | No. Contact SwiftGo to arrange a return. | |
| Cancelled by SwiftGo | Free | |
failed | Failed-attempt fee, 100% of the price by default | |
delivered | The delivery price |
Your contract can set different percentages. Live deliveries are billed to your account and invoiced at the start of each month; each delivery is billed once. A new live delivery needs room under your credit limit, counting deliveries still in progress (402 CREDIT_LIMIT_EXCEEDED otherwise). An overdue invoice pauses new live bookings (402 ACCOUNT_PAST_DUE) until it's paid.
Webhooks
Tell SwiftGo where to send status updates. Set one https URL per mode.
curl -X PUT https://api.swiftgogh.com/api/business/v1/webhook-endpoint \
-H "Authorization: Bearer sk_test_..." -H "Content-Type: application/json" \
-d '{"url": "https://yourshop.com/swiftgo/webhooks"}'
secret (whsec_…) from the first response. It isn't shown again. POST /webhook-endpoint/rotate-secret issues a new one.POST https://yourshop.com/swiftgo/webhooks
SwiftGo-Signature: t=1762942951,v1=5f2c…
SwiftGo-Event-Type: delivery.picked_up
SwiftGo-Event-Id: evt_01jcxa…
{ "id": "evt_01jcxa…", "object": "event", "type": "delivery.picked_up", "livemode": true,
"created_at": "2026-11-12T10:22:31Z",
"data": { "object": { …the delivery object… } } }
Event types: delivery.created, delivery.rider_assigned, delivery.rider_unassigned, delivery.picked_up, delivery.arriving, delivery.delivered, delivery.failed, delivery.cancelled.
- Answer with any 2xx within 8 seconds. Otherwise SwiftGo retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours.
- After 100 failures in a row the endpoint is switched off. Saving it again turns it back on.
- An event can arrive twice or out of order. Use its
idto ignore repeats, and the delivery'sstatusandupdated_atto keep the latest. GET /eventsshows recent events and whether each reached you;POST /events/{id}/resendsends one again.
Check the signature
v1 is the HMAC-SHA256 of "<t>.<raw request body>" with your secret. Reject the request if it doesn't match, or if t is more than 5 minutes old.
const crypto = require('crypto'); app.post('/swiftgo/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const header = req.get('SwiftGo-Signature') || ''; const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const expected = crypto.createHmac('sha256', process.env.SWIFTGO_WEBHOOK_SECRET) .update(`${parts.t}.${req.body}`).digest('hex'); const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; if (!fresh || !parts.v1 || parts.v1.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) { return res.sendStatus(400); } const event = JSON.parse(req.body); // update your order using event.data.object.status res.sendStatus(200); });
$body = file_get_contents('php://input'); parse_str(str_replace(',', '&', $_SERVER['HTTP_SWIFTGO_SIGNATURE'] ?? ''), $sig); $expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, getenv('SWIFTGO_WEBHOOK_SECRET')); if (abs(time() - (int) ($sig['t'] ?? 0)) > 300 || ! hash_equals($expected, $sig['v1'] ?? '')) { http_response_code(400); exit; } $event = json_decode($body, true); // update your order using $event['data']['object']['status'] http_response_code(200);
Errors
{ "error": { "type": "invalid_request_error",
"code": "QUOTE_EXPIRED",
"message": "This quote has expired. Get a new quote.",
"errors": { "quote_id": ["This quote has expired. Get a new quote."] } },
"request_id": "req_01jcxb…" }
Branch on code, which never changes. message is written for people and may change.
| HTTP | Codes | What to do |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED | Add an Idempotency-Key header. |
| 401 | AUTH_MISSING_KEY, AUTH_INVALID_KEY, AUTH_KEY_REVOKED, ACCOUNT_CLOSED | Check the key you're sending. |
| 402 | CREDIT_LIMIT_NOT_SET, CREDIT_LIMIT_EXCEEDED, ACCOUNT_PAST_DUE | Billing. Contact SwiftGo. |
| 403 | ACCOUNT_NOT_ACTIVE, ACCOUNT_SUSPENDED, TEST_MODE_ONLY | Your account isn't approved for this. |
| 404 | NOT_FOUND | Wrong ID, or it belongs to the other mode. |
| 409 | QUOTE_ALREADY_USED, DELIVERY_IN_TRANSIT, DELIVERY_FINISHED, INVALID_TRANSITION, IDEMPOTENCY_IN_PROGRESS, BUSY | The delivery or quote is in the wrong state for this. |
| 422 | VALIDATION_FAILED (see errors), OUT_OF_COVERAGE, TOO_FAR, QUOTE_NOT_FOUND, QUOTE_EXPIRED, QUOTE_MISMATCH, IDEMPOTENCY_KEY_REUSED, INVALID_WEBHOOK_URL | Fix the request. |
| 429 | RATE_LIMITED | Wait Retry-After seconds. |
| 500 | INTERNAL_ERROR | Our fault. Retry with the same Idempotency-Key. |
| 503 | LIVE_MODE_UNAVAILABLE | Live bookings are paused. Test mode still works. |
Going live
- Build and test everything with your
sk_test_key, including webhooks and the signature check. - Request live access in the developer portal (Overview → Go live). SwiftGo reviews it and sets your credit limit.
- Create a live key in the portal, set your live webhook URL, then send a test event (portal or
POST /webhook-endpoint/test). - Book one real delivery between two addresses you control.
- Switch your shop to the live key.