🤝 CRM API & Webhooks
Connect any tool to a store's Customers 360: read contacts merged across every channel (orders, quotes, invoices, WhatsApp, contact forms, newsletter, visits), with where each lead came from (Facebook post, YouTube video, Google search, e-mail campaign, affiliate, QR code…) and the next best action; push leads and activities from your forms, lead ads or another CRM; read the deal pipeline; ask your numbers in plain language; and receive signed webhooks as things happen.
Designed for server-to-server integrations: Zapier, Make, n8n, an ERP, a BI tool, a spreadsheet script. It complements the Clients API (storefront, customer token) and the v2.0 APIs.
Base URL
As with the Clients API, the base URL is the store's own domain (sub-domain or custom domain):
https://<store-domain>/api/crm/v1 — for example https://test10.devaito.com/api/crm/v1
OpenAPI 3.1
GET /openapi.json (no key needed) returns the machine-readable description of every endpoint: import it into Postman or Insomnia, or generate a client.
🔑 Authentication
- In the store admin, open Customers 360 › API & webhooks (
/admin/customers-360/api). - Click Create a key, name it after the tool that will use it, and tick only the permissions it needs.
- Copy the key: it is shown once. Devaito keeps only a SHA-256 fingerprint of the secret. You can revoke a key at any time; the page shows when each key was last used.
Keys look like dvk_<10 hex>_<32 hex>. Send them in either header:
Authorization: Bearer dvk_3f9a1c0b7e_6d2e8f...
# or
X-Api-Key: dvk_3f9a1c0b7e_6d2e8f...
A key belongs to one store and only works on that store's domain.
Permissions
| Permission | Allows |
|---|---|
contacts:lire | GET /contacts, GET /contacts/{id} |
leads:ecrire | POST /leads |
activites:ecrire | POST /activities |
affaires:lire | GET /deals |
questions:lire | GET /ask |
objets:lire | GET /objects, GET /objects/{object}/records, GET /objects/{object}/records/{id} (Studio objects) |
objets:ecrire | POST /objects/{object}/records, POST /links (Studio objects) |
Limits and language
- 120 requests per minute; beyond that the API answers
429. - Send
Accept-Language: fr,enorarto get the human-readable fields (source labels, next action, timeline titles, answers) in that language. Default: English. - Responses are JSON. Dates use
YYYY-MM-DD HH:MM:SS; the webhook envelope'screated_atis ISO 8601.
Errors
Every error has the same shape:
{
"error": {
"code": "missing_scope",
"message": "This key does not have the \"leads:ecrire\" permission."
}
}
| Status | Code | When |
|---|---|---|
| 401 | invalid_key | Missing, wrong or revoked key |
| 403 | missing_scope | The key lacks the permission of this endpoint |
| 404 | unknown_store | No store answers on this domain |
| 404 | not_found | Unknown contact id |
| 422 | validation_error | Invalid body (e.g. a lead without e-mail or phone, a field sent as an array) |
| 422 | not_understood | /ask: the question could not be turned into numbers |
| 429 | — | Rate limit reached: retry after the delay in the Retry-After header |
| 503 | store_unavailable | The store is asleep or temporarily unavailable |
| 402 | quota_reached | Studio objects: the plan's limit is reached; details.lien is the page to choose a plan |
| 409 | read_only | Studio objects: built-in objects (customer, product, order, booking, contact, quote) are read only |
| 404 | not_enabled | Studio objects are not enabled for this store |
👥 Contacts
A contact is one person, even if they ordered as a guest, sent a quote request with another phone number, wrote on WhatsApp and filled a contact form: Customers 360 merges every trace by e-mail and phone.
GET /contacts
Permission contacts:lire. Most recent activity first.
| Query parameter | Description |
|---|---|
limit | 1 to 100, default 50 |
cursor | Value of next_cursor from the previous page |
q | Search in name, e-mails and phones |
segment | tous (all, default) · a_relancer (has a next action) · devis (open quote or quote request) · meilleurs (has revenue) · nouveaux (first contact in the last 30 days) · inactifs (customers inactive for 90 days or more) |
updated_since | Only contacts with activity since this date (ISO 8601) |
Request:
curl https://test10.devaito.com/api/crm/v1/contacts?segment=a_relancer&limit=2 \
-H "Authorization: Bearer $DEVAITO_KEY" \
-H "Accept-Language: en"
Example response:
{
"data": [
{
"id": "e7a9abb64e12fa437",
"name": "Yasmine Alaoui",
"emails": [
"yasmine.alaoui@example.com"
],
"phones": [
"+212612345678"
],
"registered": false,
"source": {
"key": "facebook",
"channel": "pub",
"detail": null,
"campaign": "autumn-collection",
"label": "Facebook ad · Autumn collection",
"tracked": true
},
"orders_count": 2,
"paid_orders_count": 2,
"revenue": 1840.0,
"currency": "MAD",
"open_quotes_amount": 0,
"unpaid_amount": 0,
"channels": [
"site",
"whatsapp",
"email"
],
"first_contact_at": "2026-05-14 10:21:07",
"last_activity_at": "2026-07-06 18:02:44",
"next_action": {
"code": "reactiver",
"text": "Win them back with an offer",
"why": "2 orders, nothing for 94 days."
},
"url": "https://test10.devaito.com/admin/customers/e7a9abb64e12fa437/360"
}
],
"total": 37,
"next_cursor": "2"
}
| Field | Description |
|---|---|
source | Where the person first came from. key: facebook, instagram, youtube, tiktok, google, google_maps, chatgpt, email, affilie (affiliate), qr, whatsapp, prospection, api, airbnb, booking, agent (callback left to your assistant), import, direct… channel: pub (paid ad), social, recherche (search), local, ia (AI assistant), email, affiliation, messagerie, ota (booking platform)… tracked is true when the visit itself was observed on the site. |
channels | Every channel the person used: site, whatsapp, email, phone, quote, reservation… |
bookings_count, nights, next_booking_at | Hotels, rentals, tours, appointments: bookings (cancelled and expired holds excluded), nights stayed, next arrival |
next_action | What to do now, and why (the same suggestion the merchant sees in Customers 360), or null |
url | The contact's 360° page in the store admin |
GET /contacts/{id}
Permission contacts:lire. The same fields plus timeline (latest 100 events: visits, forms, orders, payments, quotes, messages, activities) and deals. An old id (before two records were merged) still finds the person. Postal addresses and internal notes are never returned.
{
"data": {
"id": "e7a9abb64e12fa437",
"name": "Yasmine Alaoui",
"emails": [
"yasmine.alaoui@example.com"
],
"phones": [
"+212612345678"
],
"source": {
"key": "facebook",
"channel": "pub",
"detail": null,
"campaign": "autumn-collection",
"label": "Facebook ad · Autumn collection",
"tracked": true
},
"...": "...",
"timeline": [
{
"at": "2026-07-06 18:02:44",
"type": "commande",
"title": "Order #10482",
"detail": "Cash on delivery · delivered",
"amount": 920.0
},
{
"at": "2026-05-14 10:24:51",
"type": "visite",
"title": "Left their details",
"detail": "Form /contact",
"amount": null
},
{
"at": "2026-05-14 10:21:07",
"type": "visite",
"title": "Arrived from Facebook ad · Autumn collection",
"detail": "Page /",
"amount": null
}
],
"deals": [
{
"number": "Q-2026-0042",
"stage": "accepte",
"won": true,
"lost": false,
"amount": 920.0
}
]
}
}
🧲 Leads
POST /leads
Permission leads:ecrire. Sends a lead from any tool (Typeform, Facebook Lead Ads, Google Ads lead forms, a landing page, another CRM). It appears in Customers 360 and in Audience › Leads with its source, the merchant gets a next action, and the lead.created webhook fires.
| Field | Description |
|---|---|
email or phone | Required: at least one (phone: 8 digits or more) |
name, company, city | Optional |
message | What the person asked |
source | Where the lead comes from, shown to the merchant, e.g. typeform, facebook-lead-ads |
external_id | Your id for this lead. Makes the call idempotent: sending the same id again updates the lead instead of creating a duplicate; only the fields you send are changed, and the status the merchant gave the lead is kept |
curl -X POST https://test10.devaito.com/api/crm/v1/leads \
-H "Authorization: Bearer $DEVAITO_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Karim Benali","email":"karim@example.com","phone":"+212 6 61 23 45 67","company":"Atlas Bâtiment","source":"typeform","message":"Quote for 40 m²","external_id":"tf-88123"}'
201 when created, 200 when the same external_id was updated:
{
"data": {
"id": "lead_812",
"created": true
}
}
📞 Activities
POST /activities
Permission activites:ecrire. Logs a call, e-mail, task, meeting or note on the person found by e-mail or phone (from your telephony, help desk or calendar). It appears in the contact's timeline.
| Field | Description |
|---|---|
email or phone | Required: who the activity is about |
type | call, email, task, meeting or note (default) |
subject, content, status | Optional text |
at | When it happened (ISO 8601), default now |
external_id | Idempotency: the same id updates the activity |
curl -X POST https://test10.devaito.com/api/crm/v1/activities \
-H "Authorization: Bearer $DEVAITO_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"karim@example.com","type":"call","subject":"Discovery call","content":"Wants delivery before November","at":"2026-10-09T15:30:00+01:00","external_id":"call-5531"}'
{
"data": {
"id": "activity_4410"
}
}
💼 Deals
GET /deals
Permission affaires:lire. The pipeline built from facts, not from manual updates. stage: demande (quote requested) → cree (drafted) → envoye (sent) → ouvert (viewed by the customer) → accepte → paye. The forecast weights each open deal by the probability of its stage — learned from the store's own history when there is enough of it.
{
"data": {
"open": [
{
"id": "2981",
"number": "Q-2026-0051",
"contact_id": "e5b1c0d9a2",
"contact_name": "Atlas Bâtiment",
"stage": "ouvert",
"won": false,
"lost": false,
"source": "devaito",
"amount": 48000.0,
"currency": "MAD",
"created_at": "2026-10-02",
"viewed_at": "2026-10-03 09:12:40",
"accepted_at": null
}
],
"won": [
"..."
],
"lost": [
"..."
],
"summary": {
"open_amount": 212400.0,
"weighted_forecast": 96300.0,
"won_this_month": 31500.0,
"win_rate": 0.4167,
"avg_days_to_sign": 6.5,
"currency": "MAD",
"stage_probabilities": {
"demande": 0.1,
"cree": 0.2,
"envoye": 0.3,
"ouvert": 0.5,
"accepte": 0.9,
"paye": 1
},
"probabilities_from_history": true
}
}
}
💬 Ask your numbers
GET /ask
Permission questions:lire. A question in French, English or Arabic becomes a precise query on the store's data — sales, orders, customers, products, quotes, invoices, visits, leads, by period, month, source, city… The answer says exactly how it was calculated. Through the API, questions are understood by rules only: no model, no cost.
curl "https://test10.devaito.com/api/crm/v1/ask?q=revenue%20by%20month%20this%20year" \
-H "Authorization: Bearer $DEVAITO_KEY"
{
"data": {
"question": "revenue by month this year",
"understood_as": {
"sujet": "ventes",
"groupe": "mois",
"periode": {
"cle": "annee"
},
"filtre": [],
"limite": 10,
"ordre": "desc"
},
"answer": "You collected 184,250 MAD this year. Best month: Sep (31,900 MAD).",
"value": 184250.0,
"change_pct": null,
"period": "this year",
"series": [
{
"label": "Aug",
"value": 27400.0,
"display": "27,400 MAD"
},
{
"label": "Sep",
"value": 31900.0,
"display": "31,900 MAD"
}
],
"series_type": "temps",
"how_it_is_calculated": "Paid orders (cancelled excluded) by order date, plus paid invoices without an order, in the store currency."
}
}
Other examples: new customers this month, leads by source last 30 days, unpaid invoices, bookings by channel this year, nights this quarter, chiffre d'affaires par ville ce trimestre, عدد الطلبات هذا الأسبوع.
Studio objects
Your own kinds of records — enrolments, members, rentals, tickets — defined in Studio › Objects (or by your copilot), next to the store's built-in objects. Each object has typed fields (texte, texte_long, nombre, montant, date, datetime, booleen, choix, fichier, lien); up to 6 are indexed (filterable and sortable). Built-in objects (client, produit, commande, reservation, contact, devis) are listed and readable, never written. Record ids are 26-character ULIDs. JSON shapes: Object, Record, RecordWrite, Link, LinkWrite in /openapi.json.
GET /objects
Permission objets:lire. Every object of the store with its fields (key, type, indexed, personal, required, choices, object for links).
GET /objects/{object}/records
Permission objets:lire. Filters on indexed fields (or status, customer_id, created_at, updated_at), one sort, 100 records per page at most.
GET /api/crm/v1/objects/enrolment/records?filter[level]=B1&filter[start][gte]=2026-10-01&sort=-start&per_page=50
The response has data (records: id, object, version, title, status, customer_id, values, dates) and meta (page, per_page, total, total_exact, pages).
GET /objects/{object}/records/{id}
Permission objets:lire. One record and its links (direction out or in).
POST /objects/{object}/records
Permission objets:ecrire. Without id: creates (201). With id: updates only the values sent (200, version + 1). Values are checked against the fields (types, required fields, choice lists, links that must exist in this store); unknown keys are ignored.
POST /api/crm/v1/objects/enrolment/records
{"values": {"student": 482, "level": "B1", "start": "2026-10-12"}}
POST /links
Permission objets:ecrire. Links a Studio record to a Studio record or a built-in one; both must belong to this store. Idempotent.
POST /api/crm/v1/links
{"from": {"object": "enrolment", "id": "01K…"}, "to": {"object": "client", "id": "482"}, "type": "student"}
🔔 Webhooks
In Customers 360 › API & webhooks, subscribe an HTTPS address to one or more events. Devaito POSTs a signed JSON to it within about a minute of each event. The Test button delivers a ping event right away, and every delivery can be inspected and re-sent from the same page.
Events
| Event | When |
|---|---|
lead.created | A visitor leaves their e-mail or phone on the site (with where they came from), or a lead arrives through POST /leads |
form.submitted | A contact form of the site is sent (all fields) |
order.created | A new order |
order.paid | An order becomes paid |
booking.created | A booking becomes real — confirmed or awaiting your confirmation, never an unpaid hold: hotels, rentals, tours, appointments. With dates, guests, channel (direct, airbnb, booking…), amount, currency and customer |
quote.sent, quote.viewed, quote.accepted | A quote is sent, opened by the customer, accepted |
invoice.paid | An invoice is paid |
object.created, object.updated, object.status | A record of a Studio object is created, updated, or its status changes. data: object, id, version, status, previous_status, and record (title, status, values) read at delivery time |
Payload
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Devaito-Webhooks/1.0
X-Devaito-Event: lead.created
X-Devaito-Delivery: evt_90321
X-Devaito-Signature: t=1791561600,v1=5f2c9a…
{
"id": "evt_90321",
"type": "lead.created",
"created_at": "2026-10-09T15:42:10+01:00",
"store": "test10.devaito.com",
"data": {
"kind": "website",
"name": "Yasmine Alaoui",
"email": "yasmine.alaoui@example.com",
"phone": "0612345678",
"source": {
"key": "youtube",
"channel": "social",
"detail": null,
"campaign": "demo-video",
"label": "YouTube · Demo video"
},
"landing_page": "/products/linen-shirt",
"visits": 3,
"form_page": "/contact",
"created_at": "2026-10-09 15:41:58"
}
}
Orders carry id, code, total, currency, paid, delivery_status, customer (name, e-mail, phone) and source (where the buyer came from, when known). Quotes and invoices carry number, status, total, currency and customer. Use id (also in X-Devaito-Delivery) to ignore a delivery you already processed.
Verify the signature
Each subscription has its own secret (whsec_…), shown when you create it. v1 is the HMAC-SHA256 of <t>.<raw body> with that secret. Compare in constant time and reject timestamps older than 5 minutes to block replays.
PHP
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_DEVAITO_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, getenv('DEVAITO_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
Node.js (Express)
const crypto = require('crypto');
app.post('/devaito', express.raw({ type: 'application/json' }), (req, res) => {
const sig = Object.fromEntries(req.get('X-Devaito-Signature').split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.DEVAITO_WEBHOOK_SECRET)
.update(`${sig.t}.${req.body}`).digest('hex');
const ok = sig.v1 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1))
&& Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(200);
});
Delivery and retries
- Answer with any
2xxwithin 8 seconds. Redirects are not followed. - Otherwise Devaito tries again after 1 min, 5 min, 30 min, 2 h and 6 h, then gives up on that delivery.
- After 50 failures in a row the subscription is paused; once your endpoint is fixed, a successful Test from the page turns it back on.
- Each event is delivered once per subscription; a subscription only receives events that happen after it was created.
- Addresses must be public HTTPS (port 443 or 8443). Private, local and internal addresses are refused, at creation and before every delivery.
🧩 Recipes
- Facebook / Google lead ads → Devaito: in Zapier or Make, trigger on the new lead, action “Webhooks › POST” to
/leadswithsource=facebook-lead-adsandexternal_id= the lead id. - New paid order → accounting or ERP: subscribe to
order.paid. - Sales team alerts: subscribe to
lead.createdandquote.viewed, forward to Slack or WhatsApp — the payload already says where the lead came from. - Calls from your telephony:
POST /activitieswithtype=callafter each call. - Weekly reporting: a spreadsheet script calls
/ask?q=revenue by source last week.