Errors
All errors return a JSON body with HTTP status codes. Most errors include a single detail string; validation errors include field-level details.
Standard error shape
{
"detail": "Insufficient wallet balance. Required: 4100 TZS, Available: 2500 TZS."
}
Error codes
While detail is the human-readable message, the table below is the canonical list of error conditions partners should branch on. Match on the condition column (a stable signal), not on detail text (which is free to change for clarity).
| HTTP | Condition | Typical detail |
|---|---|---|
400 | insufficient_wallet_balance | "Insufficient wallet balance. Required: X TZS, Available: Y TZS." |
400 | cancel_forbidden_post_pickup | "Rider already has the package. Create a return-to-sender shipment instead of cancelling." |
400 | cancel_forbidden_terminal_state | "Shipment is already in terminal state '<state>'." |
400 | ungeocodable_pickup | "Could not geocode pickup address: '<addr>'." |
400 | ungeocodable_dropoff | "Could not geocode dropoff address: '<addr>'." |
400 | coordinates_or_address_required | "Either coordinates or an address must be provided for pickup." |
400 | no_vehicle_fits | "No vehicle can carry this load — payload Xkg, volumetric Ykg, longest side Zcm exceeds every active vehicle's capacity." |
400 | vehicle_override_too_small | "Vehicle '<name>' cannot carry this load — payload Xkg, volumetric Ykg, longest side Zcm exceeds at least one capacity limit." |
400 | no_regional_route | "No regional route available between these locations." |
400 | wallet_inactive | "Merchant wallet is not active." |
400 | payment_already_in_progress | "A payment is already in progress." |
400 | idempotency_key_length | "Idempotency-Key must be between 16 and 255 characters." |
401 | invalid_api_key | "Invalid or expired API key" |
403 | merchant_not_approved | "Merchant account not approved" |
404 | shipment_not_found | "Shipment not found." |
422 | item_missing_dimensions | "Each item requires: weight_kg, length_cm, width_cm, height_cm." |
422 | phone_invalid | "Phone number must start with country code (e.g. +255...)" |
429 | rate_limited | "Rate limit exceeded. Retry after N seconds." |
502 | payment_gateway_error | "Payment gateway error." (or upstream Selcom message) |
We add new conditions for new failure modes — treat the list as additive. If you receive a detail that doesn't match anything above, treat it as a generic error for the HTTP status class and log it for follow-up.
Validation error shape (422)
{
"detail": [
{
"loc": ["body", "receiver_phone"],
"msg": "Phone number must start with country code (e.g. +255...)",
"type": "value_error"
},
{
"loc": ["body", "items", 0, "weight_kg"],
"msg": "ensure this value is greater than 0",
"type": "value_error.number.not_gt"
}
]
}
loc is the path to the offending field — useful for surfacing inline form errors.
HTTP status codes
| Code | Meaning | Typical cause |
|---|---|---|
400 | Bad request | Business rule violation (insufficient funds, invalid state, ungeocodable address) |
401 | Unauthorized | Missing, wrong, or revoked X-API-Key |
403 | Forbidden | Merchant account not approved or suspended |
404 | Not found | Shipment doesn't exist or doesn't belong to your account |
422 | Validation error | Field-level issues — check detail array |
429 | Rate limited | Too many requests — see Rate limits |
500 | Server error | Transient — retry with backoff |
502 | Upstream error | Payment gateway or maps service unavailable — retry |
Common errors and how to handle them
401 Unauthorized
{ "detail": "Invalid or expired API key" }
- Check
X-API-Keyheader is set and the value is intact (no extra whitespace, noBearerprefix) - Confirm you're using a sandbox key against the sandbox URL, and live key against production
- If the key was rotated, get the new one from the dashboard
403 Forbidden
{ "detail": "Merchant account not approved" }
- Your account is awaiting admin review. You'll get an email once approved.
- If approved and still seeing this, your account may be suspended — contact support.
400 Insufficient balance
{ "detail": "Insufficient wallet balance. Required: 4100 TZS, Available: 2500 TZS." }
- Top up your wallet in the dashboard
- Set up low-balance alerts to avoid this in production
400 Cannot cancel
{ "detail": "Shipment in status 'picked_up' cannot be cancelled." }
- Cancellation is only allowed before pickup. See Lifecycle.
- For post-pickup issues, contact support
400 Ungeocodable address
{ "detail": "Could not geocode dropoff address: 'somewhere in Dar'." }
- The address string is too vague. Send GPS coordinates (
dropoff_latitude+dropoff_longitude) instead.
422 Validation
Field-level issue — read the loc path:
{ "loc": ["body", "items", 0, "weight_kg"], "msg": "ensure this value is greater than 0" }
Means items[0].weight_kg failed the > 0 constraint. Fix and retry.
A common 422 is missing one of the four required dimension fields on an item:
{
"loc": ["body", "items", 0, "height_cm"],
"msg": "Field required"
}
Every item must carry weight_kg, length_cm, width_cm, and height_cm. See Rate Quotes → Item fields.
400 No vehicle fits
{ "detail": "No vehicle can carry this load — payload 350kg, volumetric 90kg, longest side 220cm exceeds every active vehicle's capacity." }
No active vehicle's three-axis capacity (max_payload_kg, max_volumetric_kg, max_length_cm) can take all your items together. The breach detail tells you which axis you've blown — usually mass, volume, or single-item length. Split the shipment, drop the offending item, or use smaller item dimensions.
If you forced vehicle_type_id and the override doesn't fit, the same error fires — overrides go through the same eligibility check, no silent upgrade. See Vehicle Types.
Rate limits
The API is rate-limited per caller. On hitting a limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{ "detail": "Rate limit exceeded. Retry after 30 seconds." }
Always honor the Retry-After header. Bulk operations should be paced — don't fan out hundreds of POST /merchant/shipments calls in a tight loop. The exact per-endpoint limits are operational and may change; treat the response (and Retry-After) as your source of truth, not a published table.
If you need a sustained burst rate above the default (e.g. a daily catalog dispatch), email support — we can raise the cap on a per-merchant basis.
Retry strategy
| Error class | Retry? | How |
|---|---|---|
4xx (except 429) | No | These won't succeed without a code change |
429 | Yes | Honor Retry-After. Use exponential backoff if absent. |
500, 502, 503, 504 | Yes | Exponential backoff, capped at 5 attempts |
| Network timeouts | Yes | Same as 5xx — but check via GET /merchant/shipments?merchant_reference=... first to avoid duplicate creation |
POST /merchant/shipments, /cancel, and /payment/initiate accept an optional Idempotency-Key header (UUID, 16–255 chars). Within a 24-hour window, a retry with the same key returns the first response verbatim instead of creating a duplicate shipment or double-debiting your wallet.
curl -X POST https://api.dodo.co.tz/api/v1/merchant/shipments \
-H "X-API-Key: $DODO_API_KEY" \
-H "Idempotency-Key: 7a4c8e2b-1f3d-4b6e-9a8d-2c5f0e7b1d4a" \
-H "Content-Type: application/json" \
-d '{ ... }'
Semantics:
- Dedup window: 24 hours from the first request. After expiry, the same key can be reused with a fresh request body — the prior cached response is dropped.
- Cache scope: keys are scoped per merchant — two merchants using the same key never collide.
- Only successful responses are cached. A failed create (any 4xx/5xx) is not cached, so you can fix the request body and retry with the same key.
- Key length: 16–255 characters. Shorter keys return
400withidempotency_key_length.
Use a fresh UUID per intended create; reuse the same key when retrying the same create. The header is optional — without it, retries can produce duplicates. Always send it on any path that might retry.
If you didn't send the header and need to verify a possibly-duplicated create after a network failure, search by your merchant_reference:
curl "https://api.dodo.co.tz/api/v1/merchant/shipments?search=your-internal-order-id-123" \
-H "X-API-Key: $DODO_API_KEY"
If a shipment with your merchant_reference already exists, don't recreate it.
Logging
Log the full response body — not just the status code — when you handle an error. The detail field is the actionable signal. For 422 errors, log the full detail array so you can replay validation failures.