Back to CallYo

CallYo API · version 1

Connect your system to CallYo

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.

1What this is

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:

  • Read orders and their status, so a warehouse knows what to pack.
  • Create orders, so a shop or funnel CallYo is not connected to can still feed the call queue.
  • Be told when something changes, instead of asking every few minutes.

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.

The full round trip: your system → 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.

2Get a key

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 label
It is shown exactly once. CallYo stores a scrambled fingerprint, not the key, so it cannot be recovered. Lost it? Revoke that key and create another — keys are independent, so nothing else breaks.

A key carries only the permissions it was given:

orders:readRead orders and their status.
orders:writePush new orders into the call queue.
stores:readList the shops on the account, and ask a key what it is.
webhooks:manageRegister and manage webhook endpoints.
events:readRead 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.

3Your first request

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.

4Reading orders

Newest first, a page at a time.
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
}
Money is a string. Do not parse it as a number. "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.

Paging

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.

Narrowing the list

Add any of these to the query string. Combine as many as you like:

statusOne or more statuses, separated by commas: status=confirmed,rejected.
store_idOne shop only. The id comes from GET /stores.
typeorder or draft. Leave it out and you get both.
sourceWhere it began: shopify, callyo_form, shopify_abandoned or api (created through this API).
created_sincePlaced at or after this moment — when it was PLACED, not when it last changed.
phoneThe customer’s phone number. +40722… and 0722… find the same person.
currencyA 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.
There is no “changed since”. 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.

What the statuses mean

pending_callIn the queue. Nobody has reached the customer yet — including after a call that went unanswered.
scheduledOn hold until a date agreed with the customer. That morning CallYo confirms it by itself — nobody rings again. scheduled_at is the date.
confirmedThe customer said yes. This is the one that means a sale.
rejectedThe customer said no.
duplicateThe same order arrived twice; this is the copy.
cancelled_externallyCancelled in the shop before a caller had decided anything.
removedGone 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.
archivedSet 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.

A decided order keeps its decision. One that was confirmed and later cancelled in the shop still reads confirmed, with cancelled_at filled in. If you ship from this, check that cancelled_at is empty before you pack.

5Pushing an order in — and on to Shopify

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.

The Idempotency-Key header is required, and it protects you

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 sendWe do
Same key, same orderReturn the same response as the first time, with Idempotent-Replayed: true. No second order.
Same key, different order409. You reused a key for new content — a bug on your side, and we will not hide it.
New key, order we already have409, 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.

The merchant’s plan has a monthly order limit. Once it is reached a create is refused with 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.

And on to Shopify

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.

pushWhat it meansWhat to do
nullNot 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.

Unless the merchant does it themselves. Some platforms create the Shopify order on their own side. A merchant can turn CallYo’s push off per shop, and then 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.
Or sends it to a warehouse instead. A merchant can set a shop so that its confirmed orders go to their warehouse, or nowhere, rather than into Shopify. For that shop no Shopify order is created: 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.

6Being told when something happens

Instead of asking us every few minutes.

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"
}
The id you need is 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.

The secret is shown once — store it now. A re-read returns everything except that field. Lost it? Delete the endpoint and register another.
Events are recorded while the account holds an API key that has not been revoked. An account that has never created a key is covered as well, from its first active endpoint — so an endpoint added on the settings page works with no key at all.

Revoking the last key stops recording, for endpoints too: that is how an integration is switched off. What was recorded before the revoke is still delivered, and Send test keeps succeeding, because a test does not depend on any of this — so a quiet endpoint with a passing test means: check that a key exists. Create a new key and recording resumes; what happened in between is not sent afterwards.

The five things we will tell you

order.createdAn order arrived — from a shop sync, the order form, or your own POST /orders.
order.status_changedIts 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.pushedAn order became a real order in Shopify. push.external_id is Shopify’s id.
order.push_failedShopify refused it, and the order went back into the call queue for a human.
order.updatedThe order itself changed: the delivery address, the basket, the total, or who the customer is — usually a caller correcting it on the phone.
A confirmation can arrive as one event or as two. Usually it is two: a caller confirms (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.

What a delivery looks like

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.

Answer with any 2xx, quickly. Do the work afterwards. Take longer than ten seconds and we treat it as a failure.

What we retry. A timeout, a network error or a 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.

How soon. Deliveries leave in batches, not one by one as things happen. A batch runs every 15 minutes, and a confirmation normally starts one straight away — so expect a confirmation promptly and anything else within a quarter of an hour. Do not build something that needs the webhook in the same second as the change.

You may receive the same event twice, and not always in order. That is normal for any webhook system. Deduplicate 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.

A test delivery is not an order event

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.

So do not require the event id on every delivery. A handler written as if (!eventId) throw rejects the very first thing we send you. Check event first and ignore a ping.
If you keep your own copy of the order, subscribe to 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.

7Checking a delivery came from us

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
}
Test the negatives, not just the happy path. A verifier that accepts everything passes the obvious test and protects nobody. Alter one byte of the body — it must fail. Check a correct body against the wrong secret — it must fail. We run both against real deliveries.

8If you cannot receive webhooks

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.

The feed keeps seven days. Stop polling for longer and you have a gap; the honest way to recover is to reconcile against GET /orders rather than assume the feed is complete.

9When something goes wrong

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.

10Reference

Everything you can call

GET/meNeeds stores:readWho this key is, and what it may do.
GET/storesNeeds stores:readThe shops on the account, and who creates their Shopify orders.
GET/ordersNeeds orders:readOrders, newest first, filtered and paged.
GET/orders/{id}Needs orders:readOne order.
POST/ordersNeeds orders:writePush an order in. Needs Idempotency-Key.
GET/webhooksNeeds webhooks:manageYour endpoints.
POST/webhooksNeeds webhooks:manageRegister one. Returns the signing secret once.
GET/webhooks/{id}Needs webhooks:manageOne endpoint (never its secret).
DELETE/webhooks/{id}Needs webhooks:manageStop it. The delivery history is kept.
POST/webhooks/{id}/testNeeds webhooks:manageSend a ping now.
GET/webhooks/{id}/deliveriesNeeds webhooks:manageWhat we sent, and what you answered.
GET/eventsNeeds events:readThe polling feed, newest first.
GET/errorsNeeds no keyEvery error code we can return.
GET/openapi.jsonNeeds no keyThe machine-readable contract.

Numbers to design around

Rate limit120 requests a minute, per key
Test deliveries10 a minute
Open endpoints30 a minute per address (/errors and /openapi.json)
Page size1 to 100, default 50
Webhook dispatchevery 15 minutes, and straight after a confirmation
Webhook timeout10 seconds to answer
Delivery retriesup to 8 attempts, over one to three hours
Auto-disableafter 20 consecutive failures
Delivery historykept 30 days
Event feedkept 7 days
Idempotency memory24 hours
Webhook endpointsup to 20 active

What this cannot do

Worth reading before you design around it — finding a missing verb halfway through a build costs far more than finding it here.

  • Create or connect shops. A merchant does that in CallYo with Shopify’s own login. The API only reads them.
  • Edit an order. There is no PATCH. Callers edit orders; you send them.
  • Delete an order. A merchant should not be able to erase a confirmed sale.
  • List what changed since a date. created_since is about when an order was placed. Changes are what webhooks and the event feed are for.
  • Tell you about CallYo’s own bookkeeping. Notes, assignment and sync stamps never produce an event — they mean nothing outside CallYo. A caller changing the order does: subscribe to order.updated.
  • Choose where a confirmed order goes. The merchant sets that per shop in CallYo: Shopify by default, their warehouse, or nowhere. The API cannot change it and does not report it.
  • Follow an order into a warehouse. The warehouse’s reference, the tracking number and the delivery status are not in the API yet.

Generate a client instead of writing one

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.

Stuck? Use Get help inside CallYo and include the request_id from the response you are asking about.