CallYo API · version 1
Send us an order, we ring the customer, and once a caller confirms it, it becomes a real order in the merchant’s Shopify store — and we tell you, with Shopify’s own order number. Read orders, push new ones in, subscribe to what happens next, from your own ERP, warehouse system or funnel.
CallYo holds a merchant’s cash-on-delivery orders and the phone calls their team makes about them. The API lets another program — not a person clicking around — do three things:
An order you create does not stop with us. When a caller confirms it, CallYo creates the real order in the merchant’s Shopify store — stock, fulfilment, courier labels and Shopify’s own reports all work from that moment. You are told when it lands, and told if it does not.
POST /orders → the call queue → a caller rings the customer → confirmed → the order appears in Shopify → order.pushed arrives at your webhook carrying Shopify’s own order id. Nothing in the middle needs a human on your side.Everything lives under https://callyo.ro/api/v1. Every response is JSON. Money is a string, never a number — more on that below, because it is the one thing that catches people out.
A key proves the request is yours. The merchant makes it once, in CallYo, under Settings → API & integrations → Create key, and pastes it into your system. It looks like this:
cyk_live_7Fq2XbW9_kR3nP...
└──┬───┘ └──┬───┘ └─┬──┘
│ │ └─ the secret — shown once, never again
│ └───────── the public handle, so keys can be told apart
└────────────────── environment labelA key carries only the permissions it was given:
orders:read | Read orders and their status. |
orders:write | Push new orders into the call queue. |
stores:read | List the shops on the account, and ask a key what it is. |
webhooks:manage | Register and manage webhook endpoints. |
events:read | Read the polling feed. |
Ask for only what you need. A key with orders:read alone cannot change anything, which makes it a safe thing to hand a reporting tool.
Confirm the key works before writing anything else:
curl https://callyo.ro/api/v1/me \
-H "Authorization: Bearer YOUR_KEY"{
"key": {
"id": "0b6c1f0e-5a3d-4c58-9d5e-2f7a1b0c9e44",
"prefix": "7Fq2XbW9",
"name": "Warehouse sync",
"scopes": ["orders:read", "stores:read"],
"rate_limit_per_minute": 120,
"created_at": "2026-09-23T08:00:00+00:00",
"last_used_at": "2026-09-23T08:14:22+00:00",
"expires_at": null
},
"firma": { "id": "3d1f6c0a-7b1e-4f0e-9c5a-8e2b4d6f1a90", "name": "Lumina Home" },
"environment": "live"
}A 401 means the key is wrong, revoked or expired. A 403 means the key is good but is missing a permission, and the message names which one.
GET /me itself needs stores:read. A new key has it ticked unless you untick it. A key without it gets a 403 on this very request, although nothing is wrong with the key — start with a request it is allowed to make.Every refusal carries a request_id — quote it if you ask for help and it takes us straight to your exact request.
curl "https://callyo.ro/api/v1/orders?limit=50" \
-H "Authorization: Bearer YOUR_KEY"One order, complete — every field we send, nothing trimmed (an order whose customer pays in the delivery country’s own money also carries customer_total and, per line, customer_unit_price; v1 grows only by adding fields, so ignore fields you do not know):
{
"id": "6fbadfa8-cac2-4bd7-81f8-999b11d1337e",
"store_id": "965069f5-1fd4-45d8-8081-acce19616532",
"status": "confirmed",
"type": "order",
"customer": { "name": "Mihai Ionescu", "phone": "0745300112", "email": null },
"shipping_address": {
"name": "Mihai Ionescu",
"line1": "Str. Exemplu 12",
"line2": null,
"city": "Cluj-Napoca",
"region": "Cluj",
"postcode": "400001",
"country": "Romania",
"country_code": "RO",
"phone": "0745300112"
},
"lines": [
{
"title": "Set 3 prosoape",
"quantity": 1,
"unit_price": "129.00",
"sku": "PRS-3",
"external_variant_id": "44012345678",
"variant_title": "Gri"
}
],
"total": { "amount": "129.00", "currency": "RON" },
"origin": {
"platform": "shopify",
"external_id": "5501234567",
"external_number": "2042"
},
"push": null,
"notes": null,
"phone_order": false,
"no_answer_count": 1,
"scheduled_at": null,
"created_at": "2026-09-23T08:30:00+00:00",
"received_at": "2026-09-23T08:31:12+00:00",
"cancelled_at": null,
"removed_at": null,
"archived_at": null
}"129.90" cannot be held exactly in most languages’ default number type, and the error compounds every time you add two together. Keep it as text, or read it into a decimal type. This is the single most common way an integration ends up a few bani out and nobody can say why.push is null here because this order came from Shopify — there is nothing to send back. For an order you created it is the answer to “did it reach the shop yet?”, covered in the next section.
type is order or draft; the list holds both. created_at is when the order was placed where it began. received_at is when CallYo last heard about it from the shop, and it does not move when a caller acts — so it cannot tell you what changed.
A list never answers with a bare array. The orders are under data, and beside them is a next_cursor:
{
"data": [ { … }, { … } ],
"next_cursor": "eyJ0IjoiMjAyNi0wOS0yM1QwODozMDowMCswMDowMCIsImkiOiI2ZmJhZGZhOC0…"
}Pass it back as ?cursor= to get the next page. A null cursor means you have everything. Page size is 1 to 100, and 50 by default. Stop when the cursor is null, not when a page looks short: with a status filter a page can come back short, or even empty, while more remain.
Add any of these to the query string. Combine as many as you like:
status | One or more statuses, separated by commas: status=confirmed,rejected. |
store_id | One shop only. The id comes from GET /stores. |
type | order or draft. Leave it out and you get both. |
source | Where it began: shopify, callyo_form, shopify_abandoned or api (created through this API). |
created_since | Placed at or after this moment — when it was PLACED, not when it last changed. |
phone | The customer’s phone number. +40722… and 0722… find the same person. |
currency | A three-letter code in capitals, such as RON. An order also carries customer_total and each line customer_unit_price when the customer pays in the money of the country the order ships to and Shopify priced it there (Shopify Markets) — e.g. a RON shop's Hungarian order in HUF — what the customer pays, in customer_total.currency. total stays the shop's books figure. v1 grows only by adding fields: ignore fields you do not know. |
created_since is about when an order was placed. An order placed on Monday and confirmed on Wednesday is not found by asking for everything created since Tuesday — so a job that polls this list for new confirmations will quietly miss some. To learn what changed, use webhooks or the event feed below. That is what they are for.pending_call | In the queue. Nobody has reached the customer yet — including after a call that went unanswered. |
scheduled | On hold until a date agreed with the customer. That morning CallYo confirms it by itself — nobody rings again. scheduled_at is the date. |
confirmed | The customer said yes. This is the one that means a sale. |
rejected | The customer said no. |
duplicate | The same order arrived twice; this is the copy. |
cancelled_externally | Cancelled in the shop before a caller had decided anything. |
removed | Gone from where it came from — deleted in the shop, or a draft or abandoned checkout that was completed there. A closed file, whatever it was before. |
archived | Set aside, still undecided: someone moved it to History in CallYo instead of calling it. |
A call nobody answered is not a status. The order stays pending_call and no_answer_count goes up by one.
confirmed, with cancelled_at filled in. If you ship from this, check that cancelled_at is empty before you pack.For a shop or funnel CallYo is not connected to. The order lands in the call queue exactly like any other, counts toward the merchant’s plan, and shows in their revenue.
curl -X POST https://callyo.ro/api/v1/orders \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-7781" \
-d '{
"store_id": "965069f5-1fd4-45d8-8081-acce19616532",
"origin": { "external_id": "7781", "external_number": "#7781" },
"customer": { "name": "Elena Radu", "phone": "0733221100" },
"shipping_address": {
"name": "Elena Radu",
"line1": "Bd. Eroilor 4, ap. 2",
"city": "Brașov",
"region": "Brașov",
"postcode": "500036"
},
"lines": [
{ "title": "Lampă de birou LED", "quantity": 2, "unit_price": "89.50" }
],
"total": "179.00"
}'It answers 201 with the order, in the same shape as above — keep its id. The store_id you send comes from GET /stores.
Send total when it includes something the lines do not — a delivery fee, a cash-on-delivery surcharge. That is the figure the caller reads to the customer, and the difference becomes a delivery line on the Shopify order.
Networks time out. When yours does you do not know whether we got the order, and retrying blindly is how a customer receives two parcels. So every create carries a key you invent, and we remember what we answered:
| You send | We do |
|---|---|
| Same key, same order | Return the same response as the first time, with Idempotent-Replayed: true. No second order. |
| Same key, different order | 409. You reused a key for new content — a bug on your side, and we will not hide it. |
| New key, order we already have | 409, naming the existing order. Your external_id is unique per shop. |
Use your own order number — order-7781. Reuse it for every retry of that order and never for anything else. We remember for 24 hours; after that the external_id rule still stops a duplicate.
403 and the code plan_locked. Nothing is created, and sending it again will not help until the month turns or the plan changes — tell the merchant rather than retrying.When the caller confirms, CallYo creates the real order in the merchant’s Shopify store: the same basket at the same prices, plus whatever your total carried beyond the lines, tagged API so it is recognisable in the Shopify admin.
The push object on every order you created says what happened. These are the parts that move:
{
"status": "confirmed",
"origin": {
"platform": "shopify",
"external_id": "7781",
"external_number": "7781"
},
"push": {
"platform": "shopify",
"state": "pushed",
"external_id": "5501234931",
"at": "2026-09-23T08:14:22+00:00"
}
}origin.external_id is still your id, exactly as you sent it. push.external_id is Shopify’s own order id.
| push | What it means | What to do |
|---|---|---|
null | Not confirmed yet. The whole object is null — there is no state to read. | Nothing. Wait for the call. |
state: "pending" | Confirmed, but Shopify was busy. We retry. | Nothing. order.pushed arrives when it lands. The customer is never rung twice for this. |
state: "pushed" | It is in the shop. | Ship it. Reconcile on push.external_id. |
state: "failed" | Shopify refused it. Back in the call queue. | Nothing automatic. Someone fixes the details and confirms again. |
A retry is never a second order. Every push is signed with the CallYo order id on the Shopify side, and we ask Shopify whether that signature already exists before creating anything — so a lost answer, a timeout or a crash halfway through cannot leave the customer with two parcels.
push stays null and no order.pushed is sent. Read pushes_api_orders on GET /stores rather than assuming: if it is false, creating the Shopify order is your job. If you both do it, the customer gets two parcels — and neither of us can detect the other’s.push stays null and no order.pushed is sent, exactly as above. The API does not yet tell you which shops are set this way, and it does not report the warehouse’s own reference or the tracking number — ask the merchant how their shop is set up.Register a URL and we POST to it when something changes. It has to be https and reachable from the public internet. The merchant can register it in Settings → API & integrations, or you can do it yourself with a key that has webhooks:manage:
curl -X POST https://callyo.ro/api/v1/webhooks \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.example/callyo/webhook",
"events": [
"order.created",
"order.status_changed",
"order.pushed",
"order.push_failed",
"order.updated"
]
}'Read the reply carefully — the endpoint is nested, and the secret is not:
{
"endpoint": {
"id": "45711d04-2ed9-4abb-b64d-55a3e5382884",
"url": "https://your-server.example/callyo/webhook",
"events": [
"order.created",
"order.status_changed",
"order.pushed",
"order.push_failed",
"order.updated"
],
"secret_prefix": "juNCYZwr",
"description": null,
"active": true,
"consecutive_failures": 0,
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-09-23T08:00:00+00:00",
"last_delivery_at": null,
"last_success_at": null
},
"secret": "cyw_juNCYZwr_udeLS0seLBMsAMQAkbnoknf3cbntlUym"
}endpoint.id, not id. Reading it from the top level gives you undefined, every later call addresses /webhooks/undefined, and all of them answer 404 — which looks exactly like a broken API rather than a typo. We know, because we did it.secret is shown once — store it now. A re-read returns everything except that field. Lost it? Delete the endpoint and register another.order.created | An order arrived — from a shop sync, the order form, or your own POST /orders. |
order.status_changed | Its standing changed: a caller confirmed, rejected or scheduled it, or marked it a duplicate; or it was cancelled in the shop, removed or archived. Carries previous_status. |
order.pushed | An order became a real order in Shopify. push.external_id is Shopify’s id. |
order.push_failed | Shopify refused it, and the order went back into the call queue for a human. |
order.updated | The order itself changed: the delivery address, the basket, the total, or who the customer is — usually a caller correcting it on the phone. |
order.status_changed) and, a moment later, the order lands in Shopify (order.pushed). But when CallYo confirms and pushes in a single step — a held order released on its date, a bulk confirm — order.pushed arrives alone, because each change raises one event and a push outranks a status change. So do not wait for both. Subscribe to both, and decide from the status and push inside the delivery, never from which event name turned up.POST https://your-server.example/callyo/webhook
Content-Type: application/json; charset=utf-8
User-Agent: CallYo-Webhooks/1
X-CallYo-Event: order.status_changed
X-CallYo-Event-Id: 7da50f76-d4e7-4e6f-902d-b0e1940cbd29
X-CallYo-Delivery-Id: 1f0c8e21-5d0a-4c3e-9a57-0d7e6b2f4c18
X-CallYo-Signature: t=1790182637,v1=82ca2a3322dff7d7b6f38a70d5c0a4f1…
{
"id": "1f0c8e21-5d0a-4c3e-9a57-0d7e6b2f4c18",
"event": "order.status_changed",
"event_id": "7da50f76-d4e7-4e6f-902d-b0e1940cbd29",
"occurred_at": "2026-09-23T21:06:01.644380+00:00",
"data": {
"order": { … the whole order, exactly as under “Reading orders” … },
"previous_status": "pending_call"
}
}The order inside is the order as it is when we send it, not as it was when the event happened — the same thing GET /orders/{id} would answer at that moment. previous_status is the one thing kept from before.
5xx is retried — up to 8 attempts in all, further apart each time, over one to three hours. A 4xx is taken as your final answer and is not retried, and a redirect is not followed: point the endpoint at its final URL. After twenty failures in a row we switch the endpoint off and tell the merchant on the settings page; a successful test switches it straight back on.X-CallYo-Event-Id, which is stable across retries — not on X-CallYo-Delivery-Id, which is new every attempt. A retry can land after a newer event, so do not rely on arrival order.Send test sends a ping. It proves your URL is reachable, your TLS is valid and your signature check works — but it describes no order, so it carries no event id at all. The X-CallYo-Event-Id header is left out, not sent empty:
X-CallYo-Event: ping
X-CallYo-Delivery-Id: 303ae418-10fe-496b-8ee5-bd353765634c
X-CallYo-Signature: t=1790182637,v1=82ca2a3322dff7d7b6f38a70d5c0a4f1…
{
"id": "303ae418-10fe-496b-8ee5-bd353765634c",
"event": "ping",
"event_id": null,
"occurred_at": "2026-09-23T21:06:01.644Z",
"data": {
"message": "This is a test delivery from CallYo. If your signature check passed, you are ready to receive order events."
}
}Test deliveries are limited to 10 a minute, and they never count toward the twenty failures that switch an endpoint off.
if (!eventId) throw rejects the very first thing we send you. Check event first and ignore a ping.order.updated. A caller correcting a delivery address is the common case, and without it your system ships to the address the customer gave originally while CallYo and Shopify both hold the corrected one. It does not fire for our internal bookkeeping, nor when a shop sync rewrites an order it did not actually change.Your webhook URL is public, so anyone could POST to it. The signature is how you tell our delivery from someone else’s. Do this before you trust the contents.
X-CallYo-Signature is t=<unix>,v1=<hex> where the hex is hmac_sha256(secret, "<t>.<raw body>"). The secret is the whole string you were given, cyw_ included. Use the raw body, before parsing — re-serialising JSON changes bytes and therefore the signature.
import { createHmac, timingSafeEqual } from 'crypto'
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`, 'utf8').digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.v1, 'hex')
if (a.length !== b.length || !timingSafeEqual(a, b)) return false
// Reject a stale timestamp, or a captured delivery can be replayed at you forever.
return Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSec
}Plenty of systems cannot accept an incoming request — an ERP behind a corporate firewall, a back office on someone’s laptop. Poll instead. The same events, in a slightly flatter shape.
curl "https://callyo.ro/api/v1/events?limit=100" \
-H "Authorization: Bearer YOUR_KEY"{
"data": [
{
"id": "7da50f76-d4e7-4e6f-902d-b0e1940cbd29",
"event": "order.status_changed",
"occurred_at": "2026-09-23T21:06:01.644380+00:00",
"order": { … the whole order, exactly as under “Reading orders” … },
"previous_status": "pending_call"
}
],
"next_cursor": null
}Two differences from a webhook body, and both are easy to trip on. The fields sit at the top level rather than under data. And id here is the event’s id — the value a webhook calls event_id and sends as X-CallYo-Event-Id. Deduplicate on it and the two channels agree.
The feed is newest first, and next_cursor walks backwards in time. To catch up, start from the top and follow the cursor until you reach an id you have already handled, then work through what you collected, oldest first. Add ?event= to read one kind only. As with a webhook, each item carries the order as it is now.
GET /orders rather than assume the feed is complete.Every refusal is the same shape — the standard one, RFC 9457 — so you can handle them all in one place.
{
"type": "https://callyo.ro/api/v1/errors#unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "That API key is not valid for this request.",
"instance": "/api/v1/me",
"code": "unauthorized",
"request_id": "fra1::sj77q-1788989400308-0289c305543f"
}Switch on code, never on the prose in detail — the wording may improve, the code will not. The complete list is at https://callyo.ro/api/v1/errors, and every type is a link you can open. A 422 for a body we could not accept also carries an errors list naming each field and what is wrong with it.
An x-retryable header says whether sending the same request again could succeed. A 429 says yes, after a wait: you are over the rate limit, and its retry-after header is the number of seconds after which the limit is certain to have cleared. Quote the request_id when you ask for support.
| Endpoint | Needs | What it does |
|---|---|---|
GET/me | Needs stores:read | Who this key is, and what it may do. |
GET/stores | Needs stores:read | The shops on the account, and who creates their Shopify orders. |
GET/orders | Needs orders:read | Orders, newest first, filtered and paged. |
GET/orders/{id} | Needs orders:read | One order. |
POST/orders | Needs orders:write | Push an order in. Needs Idempotency-Key. |
GET/webhooks | Needs webhooks:manage | Your endpoints. |
POST/webhooks | Needs webhooks:manage | Register one. Returns the signing secret once. |
GET/webhooks/{id} | Needs webhooks:manage | One endpoint (never its secret). |
DELETE/webhooks/{id} | Needs webhooks:manage | Stop it. The delivery history is kept. |
POST/webhooks/{id}/test | Needs webhooks:manage | Send a ping now. |
GET/webhooks/{id}/deliveries | Needs webhooks:manage | What we sent, and what you answered. |
GET/events | Needs events:read | The polling feed, newest first. |
GET/errors | Needs no key | Every error code we can return. |
GET/openapi.json | Needs no key | The machine-readable contract. |
| Rate limit | 120 requests a minute, per key |
| Test deliveries | 10 a minute |
| Open endpoints | 30 a minute per address (/errors and /openapi.json) |
| Page size | 1 to 100, default 50 |
| Webhook dispatch | every 15 minutes, and straight after a confirmation |
| Webhook timeout | 10 seconds to answer |
| Delivery retries | up to 8 attempts, over one to three hours |
| Auto-disable | after 20 consecutive failures |
| Delivery history | kept 30 days |
| Event feed | kept 7 days |
| Idempotency memory | 24 hours |
| Webhook endpoints | up to 20 active |
Worth reading before you design around it — finding a missing verb halfway through a build costs far more than finding it here.
PATCH. Callers edit orders; you send them.created_since is about when an order was placed. Changes are what webhooks and the event feed are for.order.updated.https://callyo.ro/api/v1/openapi.json is the full machine-readable contract, including the webhook delivery shape. Most languages have a generator that turns it into a typed client — less work and fewer mistakes than writing requests by hand.
request_id from the response you are asking about.