Skip to main content
Every account has two worlds, live and test, and the key you send decides which one you are in. There is no ?test=true, no separate host and no header: a test key can only ever see and change test things, and a live key can only ever see and change live ones. Every object says which world it belongs to:

A simulated courier that actually drives

A test delivery is not a stub. It is dispatched for real and a simulated courier takes it through the whole lifecycle on its own, at realistic timings: assigned, on the way to the store, picked up, on the way to the customer, delivered. Which means the things you actually need to build against are real:
  • Every status transition happens, in order, over several minutes.
  • Every webhook fires, including the courier position pings roughly every 20 seconds, signed exactly the way a live one is.
  • A courier turns up on the object with a name, a vehicle, a plate and a masked phone number.
  • The tracking link works and shows the courier moving.
  • Proof of delivery lands at the end: a photo, and a signature when one was captured.
So you can build and check your whole integration, including the parts that only happen while an order is in motion, without ever dispatching a real person.

Rehearse what goes wrong

The happy path is where integrations are easy. They break when an order comes back, when nobody is free to take it, or when the courier is late. On a test key you can choose which of those a delivery plays, by adding simulate to POST /v1/deliveries: Everything else about the request is the same as any other create, and everything that follows is the real thing: the same statuses, the same webhooks, the same charges, and the same rows on GET /v1/ledger that a live delivery would produce in that situation, over play money.
Use a phone number you controlListo messages the customer of a test delivery just as it does a live one, so the dropoff.phone of a rehearsal can receive a WhatsApp about the courier. Use your own number.

returned: the order comes back

Sent to POST /v1/deliveries with your usual Idempotency-Key. The create answers 201 like any other. Then the delivery goes through the normal statuses up to en_route and does not reach delivered:
  • A delivery.status_changed webhook fires for picking_up, en_route, returning and returned, with delivery.updated pings between them. The one for returning carries the store’s return code in return_verification.pincode, exactly as a live return does; see the return code.
  • failure_reason is customer_unavailable (see webhooks for the four values).
  • The money follows the live return rules. On a prepaid order the part of the delivery fee you covered at the create comes back in full (a subsidy_refund on GET /v1/ledger), and the wasted trip is billed as one provider_charge of twice the trip fee, covering both legs. charges on the delivery lists it as a single return_fee entry (not one per leg), and refunds stays empty.
  • A returned delivery is final. POST /v1/deliveries/{id}/retries refuses it with 409 not_retriable, because the order ended; create a new delivery under a new external_id to try again.

no_courier: nobody is free

The answer is 409, the same one a live create gets:
Nothing was created: no delivery, no debit and nothing stored under your Idempotency-Key. So there is no delivery to retry with POST /v1/deliveries/{id}/retries; the way to retry is to send the create again. The address is checked first, so a dropoff outside the service area gets its 422 out_of_service_area instead, as it would live. Because simulate is part of the request, sending this Idempotency-Key again without simulate is a fresh request and creates the delivery normally.

delayed_pickup: the courier is late

The delivery is created and delivered, but slowly. It goes through the same statuses as the normal run: it reaches picking_up about a minute after the create (that is when the courier is assigned), en_route about 25 minutes after it and delivered about 30 minutes after it. When you send a pickup_ready_at, the delivery waits in scheduled and the same clock runs from that pickup time instead of from the create. Use it to see what your system shows during a long wait and how pickup_eta_at and dropoff_eta_at move.

The courier_assigned status does not show in test mode

In test mode a delivery may go from dispatching straight to picking_up without ever showing a courier_assigned status. That held in every scenario, the normal run included. Do not wait for it: read courier_assigned_at or the courier object, which are filled in.

Rules that apply to all three

  • simulate works on a test key only. A live key that sends it gets 422 validation_failed with param: "simulate", and so does any value that is not one of the three. Ship the same code to production and the field is refused loudly rather than ignored.
  • The same Idempotency-Key with a different simulate is a 409 idempotency_conflict, like any other change to the body, once a request has used the key (it created a delivery, or it is still being processed). A request that was refused before it created anything, such as the no_courier 409, leaves the key free: send it again with a different simulate, or none, and it is a new request.
  • There is no scenario for a courier who cancels before pickup. That cannot be simulated, so it is not offered; test your handling of canceled with a cancellation of your own.

Play money

The test balance is real bookkeeping over fake money. Top it up from the partner dashboard with a fake top-up: no card and no transfer, the balance simply goes up. Everything downstream of that behaves like the live ledger:
  • Your share of each delivery fee is debited at create, and a create the balance cannot cover is refused with 402 insufficient_balance exactly as it would be live.
  • A cancelled or failed delivery refunds it, and bills the same wasted-trip charge for a late cancellation.
  • Cash orders credit their order value on delivery and hold it, then release it to available_cents overnight once it is a day old, rather than on the weekly settlement cycle, so you can watch both states without waiting a week.
  • Withdrawals can be requested and resolve themselves to paid about three minutes later. No money is sent anywhere, and the withdrawal.updated webhook fires for real.
GET /v1/ledger reads back on a test key too, with every row carrying "test": true, so you can build and check your reconciliation against play money before a peso is at stake. Card orders in test mode take test cards only. Test stores carry no plan. plan is null on every one of them, nothing is ever charged for one, and no quote or delivery is refused with plan_required. Plan Listo starts with your first live store; see plan and billing.

The two worlds cannot see each other

A test key asking for a live object gets 404 not_found, and the other way round. It is not a permission error, because from a key’s point of view the other world’s objects never existed. That extends to the things you name yourself: external_id and Idempotency-Key are unique per mode. Your staging system and your production system can both dispatch order-841 on the same day without colliding, so you never need mode-specific prefixes.
Set the test endpoint separatelyWebhook endpoints and signing secrets are per mode too. Setting the live pair does not give you test events, and pointing both modes at one endpoint means you cannot tell a rehearsal from a real order except by reading test on the payload. Use two endpoints.

What test mode is not for

  • It is not a load environment. The same rate limits apply, and the simulated courier takes the same few minutes a real one does.
  • It is not a sandbox for addresses. The service area is enforced the same way, so a test dropoff still has to be within reach of the test store you dispatch from.
  • It does not expire. Leave your staging environment pointed at a test key permanently.
Everything you should check before switching a key is in go live.