Platform
Internal API
How Gumza provisions a delivery account on Gumza Dispatch the moment a merchant finishes signup. Documented because it is the pattern to copy if you are putting your own front door in front of Dispatch.
What it solves
A merchant should sign up once. They fill in one form on Gumza, and deliveries work — no second signup on Dispatch, no copying an API key between two dashboards. So at the moment signup completes, the Gumza backend calls Dispatch, creates the matching merchant, and stores the API key and webhook secret it gets back.
The merchant never sees any of it. What they see is a Delivery settings page that already says Connected.
Authentication
One shared secret, INTERNAL_API_KEY, set to the same value on both deployments and sent as X-Internal-Key. It is compared in constant time. If the variable is unset, the surface answers 503 INTERNAL_API_DISABLED — off, never open.
X-Internal-Key: <32+ random characters, identical on both sides>
Content-Type: application/jsonEndpoints
- POST
/api/v1/internal/merchantsCreate a merchant and optionally issue its credentials - POST
/api/v1/internal/merchants/:merchantId/credentialsRe-mint credentials for a merchant that already exists
Create a merchant
| Field | Type | Description |
|---|---|---|
| name | string required | Business name. The slug is derived from it, with a random suffix if that slug is taken. |
| contactEmail | string required | Normalised to lowercase. |
| contactPhone | string required | Normalised to Kenyan format; an unparseable number is a 422. |
| category | enum optional | fashion, beauty, electronics, pharmacy, food, retail or other. |
| salesChannels | enum[] optional | online_store, instagram, whatsapp, in_store. Defaults to empty. |
| defaultPickupAddress | string optional | Where riders collect. Set it here or the merchant cannot dispatch from a connected store. |
| tenantSlug | string optional | Which operator tenant to create under. Defaults to the configured self-signup tenant. |
| issueApiKey | boolean optional | Defaults to true. Returns the plaintext key once. |
| webhookUrl | string optional | Your public endpoint for status events. Setting it returns a webhook secret, once. |
| gumzaStaffId | uuid optional | Who completed signup. Audit trail only. |
curl -X POST https://api.dispatch.gumza.ke/api/v1/internal/merchants \
-H "X-Internal-Key: $INTERNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Example Stores",
"contactEmail": "owner@example.invalid",
"contactPhone": "+254700000001",
"category": "retail",
"salesChannels": ["whatsapp", "in_store"],
"defaultPickupAddress": "Shop 4, Westlands, Nairobi",
"issueApiKey": true,
"webhookUrl": "https://gumza.ke/webhooks/dispatch/easybox"
}'
# → {
# "success": true,
# "data": {
# "merchantId": "8f1c…",
# "tenantId": "2a7e…",
# "slug": "example-stores",
# "apiKey": "ebx_7f3c91a2.9Xq2…", // shown once, never again
# "webhookSecret": "kR3n…" // shown once, never again
# }
# }Repairing a half-finished link
The interesting case is not the happy path. It is: Dispatch created the merchant, answered 200, and then the caller could not keep the credentials — because its own encryption key was not configured, or the write failed after the merchant row was already recorded. The merchant exists and is unusable.
The credentials endpoint is the repair. It is what sits behind the Connect delivery button on the Gumza settings page.
curl -X POST https://api.dispatch.gumza.ke/api/v1/internal/merchants/$MERCHANT_ID/credentials \
-H "X-Internal-Key: $INTERNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "issueApiKey": true }'issueApiKeyis additive. Existing keys keep working, so this is safe to call more than once and safe to call on a live merchant.webhookUrlis not additive — passing it rotates the webhook secret and invalidates the previous one. Send it only when you have no usable secret.- A suspended merchant is refused with
409. Handing out a working key for an account whose sessions are already rejected would only produce confusing failures later.
Failure modes worth handling
| Response | Means | Do |
|---|---|---|
| 503 INTERNAL_API_DISABLED | No shared key configured on the Dispatch side | Create the account without a delivery link; let the merchant retry later |
| 401 | Keys do not match between the two deployments | Fix the configuration; do not retry |
| 409 | A merchant with these details already exists, or is suspended | Repair with the credentials endpoint instead of creating again |
| 422 | Unparseable phone, or a name with no letters or digits to slug | Surface the field error in your signup form |
| 502 / timeout | Dispatch unreachable | Finish signup anyway; the merchant retries from Settings |
Shared identity
Both products validate sessions against the same JWT_SECRET, so a token issued by one is accepted by the other. That is what lets a merchant move between the Gumza dashboard and Dispatch views without signing in twice. If you are running both, the two secrets must match — and every other rule about them applies twice over.