Skip to main content

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).

HTTPConditionTypical detail
400insufficient_wallet_balance"Insufficient wallet balance. Required: X TZS, Available: Y TZS."
400cancel_forbidden_post_pickup"Rider already has the package. Create a return-to-sender shipment instead of cancelling."
400cancel_forbidden_terminal_state"Shipment is already in terminal state '<state>'."
400ungeocodable_pickup"Could not geocode pickup address: '<addr>'."
400ungeocodable_dropoff"Could not geocode dropoff address: '<addr>'."
400coordinates_or_address_required"Either coordinates or an address must be provided for pickup."
400no_vehicle_fits"No vehicle can carry this load — payload Xkg, volumetric Ykg, longest side Zcm exceeds every active vehicle's capacity."
400vehicle_override_too_small"Vehicle '<name>' cannot carry this load — payload Xkg, volumetric Ykg, longest side Zcm exceeds at least one capacity limit."
400no_regional_route"No regional route available between these locations."
400wallet_inactive"Merchant wallet is not active."
400payment_already_in_progress"A payment is already in progress."
400idempotency_key_length"Idempotency-Key must be between 16 and 255 characters."
401invalid_api_key"Invalid or expired API key"
403merchant_not_approved"Merchant account not approved"
404shipment_not_found"Shipment not found."
422item_missing_dimensions"Each item requires: weight_kg, length_cm, width_cm, height_cm."
422phone_invalid"Phone number must start with country code (e.g. +255...)"
429rate_limited"Rate limit exceeded. Retry after N seconds."
502payment_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

CodeMeaningTypical cause
400Bad requestBusiness rule violation (insufficient funds, invalid state, ungeocodable address)
401UnauthorizedMissing, wrong, or revoked X-API-Key
403ForbiddenMerchant account not approved or suspended
404Not foundShipment doesn't exist or doesn't belong to your account
422Validation errorField-level issues — check detail array
429Rate limitedToo many requests — see Rate limits
500Server errorTransient — retry with backoff
502Upstream errorPayment gateway or maps service unavailable — retry

Common errors and how to handle them

401 Unauthorized

{ "detail": "Invalid or expired API key" }
  • Check X-API-Key header is set and the value is intact (no extra whitespace, no Bearer prefix)
  • 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 classRetry?How
4xx (except 429)NoThese won't succeed without a code change
429YesHonor Retry-After. Use exponential backoff if absent.
500, 502, 503, 504YesExponential backoff, capped at 5 attempts
Network timeoutsYesSame as 5xx — but check via GET /merchant/shipments?merchant_reference=... first to avoid duplicate creation
Idempotency on create

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 400 with idempotency_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.