cashod
Developers · API v1

Plug your own systems into Cashod.

The Cashod API lets your own systems push COD orders into Cashod, read their delivery status back, and keep your product catalogue and stock in sync. It is a JSON HTTP API — no SDK required.

Base URLhttps://api.cashod.ma/api/v1
Create an order
$ 
Response
Authentication

One key, one store, only the scopes you tick.

Create a key in Cashod under Settings → Developers. The secret is shown once, at creation, and is not recoverable afterwards — if it is lost, revoke the key and create another.

Send it as a bearer token on every request:

Authorization
Authorization: Bearer csk_live_…
A key belongs to one store.

It sees and changes that store's data and nothing else, so you never pass a store id — the key already says which store you mean. A merchant running several stores creates a key per store.

Scopes

Each key also carries scopes, ticked when you create it. A write scope also grants the matching read, so a key that creates orders can read them back. Give a reporting script a read-only key and it cannot create or cancel anything, even if it leaks.

  • orders:readRead orders and their delivery status
  • orders:writeCreate orders — also grants orders:read
  • products:readRead the product catalogue
  • products:writeCreate and update products — also grants products:read
  • stock:writeSet variant stock levels
Quick start

Your first order in three steps.

  1. 01
    Create a key

    In Cashod, open Settings → Developers, tick the scopes you need and copy the secret — it is shown only once.

  2. 02
    Send an order

    POST it to /v1/orders with your own external_id, so a retried request can never become a second parcel.

  3. 03
    Follow its delivery

    Read status, confirmation_status and tracking_number back from /v1/orders/{id} as the courier moves it.

POST /v1/orders
curl -X POST https://api.cashod.ma/api/v1/orders \
  -H "Authorization: Bearer csk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "order-77",
    "customer": { "name": "Amine Berrada", "phone": "+212600000000" },
    "shipping": { "address": "12 Rue Tarik Ibn Ziad", "city": "Casablanca" },
    "items": [ { "sku": "MON-001-M-NOIR", "quantity": 2 } ]
  }'
Endpoints

A small surface you can learn in an afternoon.

Everything lives under https://api.cashod.ma/api/v1.

POST/v1/orders

Create an order from your checkout. Safe to retry with external_id.

Scopeorders:write
GET/v1/orders/{id}

Read one order: shipping status, confirmation status and tracking number.

Scopeorders:read
GET/v1/orders

List orders newest first, filter on created_after, paginate with a cursor.

Scopeorders:read
POST/v1/products

Create or update a product by SKU, with its variants and images.

Scopeproducts:write
PATCH/v1/products/{productId}/variants/{variantId}/stock

Set a variant's stock to an absolute level.

Scopestock:write
GET/v1/openapi.json

The machine-readable OpenAPI document the reference is built from.

AI assistant

Build it with an AI assistant

Using an AI coding assistant? Copy this prompt instead of translating the page by hand. It never contains a key: the code it produces reads yours from a CASHOD_API_KEY environment variable.

Paste it into ChatGPT, Claude, Cursor or Lovable. It carries this page's rules and a link to the OpenAPI document, so the assistant writes the integration for your stack.

cashod-api-prompt.md
You are helping me integrate my system (online store, landing page or ERP) with the Cashod API, a cash-on-delivery order platform. Write the integration code following the specification below exactly. Ask me which language/framework I use if you cannot tell from context, and which of the tasks below I need.

The full machine-readable specification is at https://api.cashod.ma/api/v1/openapi.json. Fetch it if you can, and trust it over this summary for field details.

## Rules
- Call the API from my SERVER only, never from browser JavaScript: the key must not reach visitors.
- Read the key from an environment variable named CASHOD_API_KEY (it looks like csk_live_…). Never hard-code it. I create it in Cashod under Settings → Developers; one key belongs to one store.
- Every request: header "Authorization: Bearer <CASHOD_API_KEY>", JSON bodies with "Content-Type: application/json".
- Send only the fields listed here or in the OpenAPI document: unknown fields are rejected with 400 invalid_request.
- Rate limit: 120 requests per minute per key.
- Never let a Cashod failure break my customer's checkout: log it and retry in the background.

## Task 1: send an order
POST https://api.cashod.ma/api/v1/orders  (scope orders:write)
{
  "external_id": "order-77",
  "customer": { "name": "Amine Berrada", "phone": "+212600000000" },
  "shipping": { "address": "12 Rue Tarik Ibn Ziad", "city": "Casablanca" },
  "items": [ { "sku": "MON-001-M-NOIR", "quantity": 2, "unit_price": 149 } ]
}
- Always send external_id, set to my own order id. It makes the call idempotent: a retry returns the existing order (200) instead of creating a second one (201). Without it a retried timeout becomes a second real parcel.
- customer.name, customer.phone, shipping.address, shipping.city are required. shipping.phone is optional (defaults to the customer's).
- items: at least one. Each needs quantity (integer ≥ 1) and one of: sku (variant or product SKU, the usual way), product_id (Cashod product id), or name (free text for an item not in the catalogue; unit_price is then required).
- unit_price: what the customer actually paid per unit. Omit it to use the catalogue price.
- Optional: total (final agreed total; the difference is recorded as a discount), discount, notes, scheduled_for (ISO 8601), source_name (e.g. "Landing page Ramadan"), confirmed (true ONLY if I already confirmed the order with the customer; it skips the confirmation call).

## Task 2: read delivery status
GET https://api.cashod.ma/api/v1/orders/{id}  or  GET https://api.cashod.ma/api/v1/orders?created_after=<ISO 8601>  (scope orders:read)
- An order has status (shipping, e.g. NOT_SHIPPED, SHIPPED, DELIVERED, RETURNED), confirmation_status (e.g. PENDING, CONFIRMED, CANCELLED) and tracking_number once shipped.
- Lists are cursor-paginated, newest first: read data, has_more and next_cursor, and pass ?cursor=<next_cursor> for the next page. limit defaults to 50, max 100.
- Poll at most once a minute.

## Task 3: sync products
POST https://api.cashod.ma/api/v1/products  (scope products:write)
- Keyed on sku: an unknown sku creates the product (201), a known one updates it (200). Safe to send the whole catalogue on a schedule.
- Required: sku, name, category, price, cost_price. Optional: description, sale_price, is_active, track_stock, min_stock, images (URLs), variants.
- variants: each needs sku; optional size, color, price, sale_price, cost_price, stock. Variants are matched by sku; one I do not list is left untouched, never deleted.
- images: omit to keep the existing ones, send a list to replace them, send [] to clear them.
- Setting stock here also needs the stock:write scope.
- 409 conflict: another product already uses this name, or a variant sku belongs to another product. Report it to me; do not retry.

## Task 4: keep stock in sync
PATCH https://api.cashod.ma/api/v1/products/{productId}/variants/{variantId}/stock  (scope stock:write)
{ "stock": 12 }
- Stock is set to an absolute level, never a delta, so a retried call cannot double-apply.

## Errors
Every failure looks like {"error": {"type": "...", "message": "..."}}. Branch on error.type, never on the message text.
- invalid_request (400): fix the payload, do not retry.
- authentication_error (401): missing, unknown or revoked key. Do not retry.
- permission_error (403): the key lacks the scope, or the subscription is not active. Do not retry.
- not_found (404): no such order or product in this key's store.
- rate_limit_exceeded (429) and server_error (500), plus timeouts and network errors: retry with backoff (for example 1 min, 5 min, 30 min). Orders are safe to retry because of external_id.

New optional fields may appear in responses: ignore what you do not recognise.

## When you are done
Show me where the calls happen in my code, the environment variable to set, and a curl command to send one test order.
Guides

What the field list can't tell you.

Create an order

Identify each item by its SKU — a variant SKU or a product SKU, whichever you sell by. Leave unit_price out and the catalogue price is used; send it and it wins, which is what you want when the landing page ran a promotion.

POST /v1/orders
curl -X POST https://api.cashod.ma/api/v1/orders \
  -H "Authorization: Bearer csk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "order-77",
    "customer": { "name": "Amine Berrada", "phone": "+212600000000" },
    "shipping": { "address": "12 Rue Tarik Ibn Ziad", "city": "Casablanca" },
    "items": [ { "sku": "MON-001-M-NOIR", "quantity": 2 } ]
  }'

Idempotency — send external_id

Send your own id for the order as external_id and the call becomes safe to retry. If the same external_id arrives again, Cashod returns the order it already created rather than creating a second one:

  • 201 Created — a new order now exists.
  • 200 OK — this external_id already had an order; it is returned unchanged.

This matters more than it looks. A request that times out has usually still been processed; without an external_id, your retry becomes a second real order, dispatched to a real courier, and the customer receives two parcels.

Read delivery status

An order carries status (shipping), confirmation_status (the confirmation call) and tracking_number once it ships.

GET /v1/orders/{id}
curl https://api.cashod.ma/api/v1/orders/ORDER_ID \
  -H "Authorization: Bearer csk_live_…"

To follow many orders, list them with GET /v1/orders and filter on created_after. Poll no more than once a minute; there is nothing to gain from tighter loops, since statuses change at courier pace.

Create and update products

One endpoint keyed on your sku: an unknown SKU creates the product, a known one updates it. So you can send your whole catalogue on a schedule without checking what already exists.

  • 201 Created — this SKU was new.
  • 200 OK — a product with this SKU existed and was updated.
POST /v1/products
curl -X POST https://api.cashod.ma/api/v1/products \
  -H "Authorization: Bearer csk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "sku": "MON-001",
    "name": "Montre Or",
    "category": "Accessoires",
    "price": 199,
    "cost_price": 60,
    "images": [ "https://cdn.example/montre.jpg" ],
    "variants": [
      { "sku": "MON-001-M-NOIR", "size": "M", "color": "Noir", "stock": 7 }
    ]
  }'

Variants are matched by SKU, and one you do not list is left untouched — never deleted. A variant usually has order history behind it, so a sync that happens to omit one must not destroy it. Delete a variant in the Cashod dashboard.

Images work the other way: omit the images field and the existing ones are left alone; send a list and it becomes the set; send [] to clear them.

cost_price is required. Cashod computes COGS and profit from it, and a product created without one reports a 100% margin on every order it appears in. On an update it is ignored once there is stock bought against the product — changing it then would rewrite the cost of stock you already hold.

Setting stock on a variant through this endpoint also needs the stock:write scope. Without it the call is refused rather than quietly saving everything except the stock — so a catalogue key stays a catalogue key.

Two conflicts are worth handling: another product in the store already uses that name (names are unique per store, separately from SKUs), or one of your variant SKUs belongs to a different product. Both come back as 409 conflict naming the collision.

Keep stock in sync

Stock is set to an absolute level, not adjusted by a delta. Send the level you want the variant to have — that way a retried call cannot double-apply, which a -1 silently would.

PATCH /v1/products/{productId}/variants/{variantId}/stock
curl -X PATCH https://api.cashod.ma/api/v1/products/PRODUCT_ID/variants/VARIANT_ID/stock \
  -H "Authorization: Bearer csk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "stock": 12 }'

Pagination

List endpoints are cursor-paginated, newest first. A response carries data, has_more and next_cursor. Pass that cursor back as ?cursor= for the next page. Page size defaults to 50 and caps at 100.

Cursors rather than page numbers because orders arrive constantly: with offsets, a new order shifts every later page down by one, so a client walking their history silently misses rows and re-reads others.

Errors

Every failure has the same shape:

application/json
{
  "error": {
    "type": "invalid_request",
    "message": "No product or variant with SKU \"GHOST-1\" in this store."
  }
}

Branch on type, never on the wording of message — the wording may be improved, the type will not change.

400invalid_requestSomething in your payload. The message names it.
401authentication_errorMissing, unknown or revoked key.
403permission_errorThe key lacks the scope, or the account's subscription is not active.
404not_foundNo such order or product in this key's store.
429rate_limit_exceededBack off and retry.
500server_errorOur side. Safe to retry, and safe twice if you sent an external_id.

Rate limits

0requests per minute, per key

120 requests per minute per key. The limit is per key rather than per IP address, so another client on the same hosting cannot spend your allowance.

Versioning

The version is in the path: /v1. New optional fields may be added to responses within a version, so parse defensively and ignore what you do not recognise. Anything that would break an existing integration goes into a new version instead.

API reference

Every endpoint, every field.

Generated from the backend's own OpenAPI document, so it changes with the code on every deploy and cannot go stale.

openapi.json
Cashod API for developers: orders, stock and webhooks