
Send WhatsApp templates to your clients, download files from your tables, and get a signed webhook whenever something changes in your business. Authenticate with an API key you mint from your business settings.
All API requests go to the gateway host:
https://crimbo-gateway-833321703837.me-west1.run.appEvery request must include your API key in the X-API-Key header. Keys are issued from your Business Settings page and are scoped to your business.
X-API-Key: crm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxNeed a key? Open Business Settings → API Access. The plaintext value is shown exactly once at creation - copy it then or revoke and create a new one.
Each key carries one or more scopes that decide what it may do. Scopes are chosen when the key is created and cannot be changed later - to change what an integration may do, create a new key and revoke the old one.
| Scope | Description |
|---|---|
messages:send | Send WhatsApp messages: /send-template and /send-action. Keys created without choosing a scope get this one. |
collections:read | Download files stored in your tables. Cannot send anything, so it is the safe key to give a webhook receiver. |
client_tags:read | List your tags and read which tags each client has. Read-only. |
client_tags:write | Add, replace and remove tags on your clients. Keys with this scope can change how your clients are segmented, so give it only to systems you trust with that. |
Calling an endpoint your key is not scoped for returns 403:
{
"error": "This API key does not have the \"collections:read\" scope. Issue a key with that scope in Settings.",
"code": "API_KEY_SCOPE_MISSING",
"required_scope": "collections:read"
}Each API key may make up to 600 requests per minute, measured over a sliding window. Requests from a single IP address are also capped at 1,200 per minute across all keys.
Going over returns 429. Wait a few seconds and retry, backing off further if the 429 repeats.
HTTP/1.1 429 Too Many Requests
{
"error": "Too many requests",
"message": "Please slow down and try again shortly."
}POST/api/public/v1/send-template
Sends an approved Meta WhatsApp template to a phone number. If no client with that phone exists for your business, one is auto-created - pass an optional client.name / client.email to populate it.
| Field | Type | Required | Description |
|---|---|---|---|
template_name | string | Yes | Approved Meta template name (case-sensitive). |
language | string | Yes | Template language as registered with Meta (e.g. "he", "en_US"). |
phone_number | string | Yes | Recipient phone number. E.164 format ("+972…") is preferred; local Israeli numbers are normalised automatically. |
parameters | string[] | No | Positional values for {{1}}, {{2}}, … in the template body. Order matters. |
header_media | { type, url, filename? } | No | Required when the template has an image, video or document header; omit it otherwise. type is "image", "video" or "document" and must match the template's header. url must be a public https link: WhatsApp downloads the file from it. filename is optional and for documents only. |
button_parameters | { index, value, type? }[] | No | Required when a button's link ends in {{1}}; otherwise needed only to set a quick-reply payload. index is the button's 0-based position in the template. For a link, value is only the part that replaces {{1}}: WhatsApp adds it to the end of the link the template declares. type is "url" (the default), or "quick_reply" to set the payload you get back when the customer taps that button. |
client.name | string | No | Display name used when auto-creating a client. Ignored if a client with this phone already exists. |
client.email | string | No | Email used when auto-creating a client. Ignored if a client with this phone already exists. |
curl -X POST https://crimbo-gateway-833321703837.me-west1.run.app/api/public/v1/send-template \
-H "X-API-Key: $CRIMBO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_name": "appointment_reminder",
"language": "he",
"phone_number": "+972501234567",
"parameters": ["John", "10:00"],
"client": { "name": "John Doe" }
}'WhatsApp rejects such a template with error 132012 unless the send names the file. Pass header_media with a public https link; the type must match the template's header. A 200 response only means WhatsApp accepted the request: a link it cannot use (unreachable, too large, or a video that is not mp4 with H.264 video and AAC audio) fails later, as error 131053 in the delivery-status webhook.
curl -X POST https://crimbo-gateway-833321703837.me-west1.run.app/api/public/v1/send-template \
-H "X-API-Key: $CRIMBO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_name": "product_launch",
"language": "en_US",
"phone_number": "+972501234567",
"header_media": { "type": "video", "url": "https://example.com/launch.mp4" }
}'If a button's link in the template ends in {{1}} (for example https://example.com/invoices/{{1}}), pass button_parameters with the button's index and only the part that replaces {{1}}. Send "abc123", not the whole address: WhatsApp appends your value to the link the template declares, and nothing checks that the result works. Line breaks, tabs and repeated spaces in a value become a single space.
curl -X POST https://crimbo-gateway-833321703837.me-west1.run.app/api/public/v1/send-template \
-H "X-API-Key: $CRIMBO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_name": "invoice_ready",
"language": "en_US",
"phone_number": "+972501234567",
"parameters": ["John"],
"button_parameters": [{ "index": 0, "value": "abc123" }]
}'{
"success": true,
"message_id": "wamid.HBgLOTcyNTAxMjM0NTY3FQIAERgSMjA4Q0...",
"client_id": 1234,
"client_created": false
}message_id is Meta's WAMID for the dispatched template. client_created tells you whether the call created a new client row.
POST/api/public/v1/send-action
Use this instead of /send-template when your system already emits its own payloads. You keep sending your own action names and your own field names - nested however you like - and we translate them into the template Meta expects. Same API key, same delivery, same response.
It works off a one-time setup: your partner declares which template each action sends and which of your fields fills each of the template's variables, on their partner page. That configuration covers every business they manage, so onboarding another business needs no change on your side.
| Field | Type | Required | Description |
|---|---|---|---|
actionType | string | Yes | The action name your partner configured. Matched case-insensitively. |
recipient | string | Yes | Recipient phone number. E.164 format ("+972…") is preferred; local Israeli numbers are normalised automatically. phone_number is accepted as a synonym. |
language | string | No | Template language. Falls back to the language configured for the action, and is required when the action has none. |
…any other field | string | number | boolean | No | Everything else is yours. Nest it however your system already does - the configuration points at each value by path, e.g. customer.customerName or extraFields.invoiceUrl. |
Fields like business.businessName or customer.customerName are values only. They fill message variables; they never decide which business sends or which client receives. Your API key identifies the sender and recipient identifies the customer.
curl -X POST https://crimbo-gateway-833321703837.me-west1.run.app/api/public/v1/send-action \
-H "X-API-Key: $CRIMBO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"actionType": "OPTIMUS_UPDATE_EVENT_TIME",
"recipient": "0501234567",
"language": "he",
"customer": {
"customerName": "יוסי כהן"
},
"appointment": {
"eventDate": "15/06/26",
"eventStartTime": "10:00"
},
"business": {
"businessName": "מספרת דנה",
"businessPhone": "031234567"
}
}'A different action needs different fields, and nothing about the request changes but the body. A value like an invoice URL can fill a message variable or the dynamic part of a button's link, depending on how the action was configured.
One thing to know if a value feeds a button link: WhatsApp adds it to the end of the link the template already declares, so a template linking to https://example.com/{{1}} wants just the reference. Keep sending whole addresses if that is what you have - your partner tells us which start to remove, and a link pointing somewhere unexpected is rejected rather than sent broken.
{
"actionType": "OPTIMUS_INCOMING_INVOICE_NO_BUTTON",
"recipient": "0501234567",
"language": "he",
"business": {
"businessName": "מספרת דנה",
"businessPhone": "031234567"
},
"extraFields": {
"invoiceUrl": "https://example.com/invoices/abc123"
}
}{
"success": true,
"message_id": "wamid.HBgLOTcyNTAxMjM0NTY3FQIAERgSMjA4Q0...",
"client_id": 1234,
"client_created": false
}A mapped field that is absent or empty fails the request before anything is sent, because WhatsApp rejects a message with an empty variable anyway. Every missing field is listed at once, each naming the path it was expected at, so one round-trip tells you everything to fix. Your partner can set a fallback value per field to make one optional.
{
"error": "Payload is missing values for: body variable {{1}} (expects \"customer.customerName\")",
"code": "ACTION_FIELD_MISSING",
"missing_fields": [
{ "target": "body", "position": 1, "path": "customer.customerName" }
]
}GET/api/public/v1/collections/:collectionId/rows/:rowId/files/:columnKey
Fetches the file stored in one cell of a table. This is the downloadUrl that every file cell carries in a collection_row webhook, so you rarely build it yourself. Requires the collections:read scope.
The response is a 302 redirect to a signed storage link that expires after 15 minutes. The downloadUrl itself never expires, so it is safe to store and fetch later. Add ?disposition=inline to have the file shown in the browser rather than downloaded.
# 1. Ask for the file. The key goes only to us; the answer is a redirect.
read -r STATUS SIGNED_URL < <(curl -s -o response.json -w '%{http_code} %{redirect_url}' \
-H "X-API-Key: $CRIMBO_API_KEY" \
https://crimbo-gateway-833321703837.me-west1.run.app/api/public/v1/collections/12/rows/4471/files/quote)
if [ "$STATUS" != "302" ]; then
echo "No download link (HTTP $STATUS):" >&2; cat response.json >&2; exit 1
fi
# 2. Download from the signed link, without the key.
curl -f -o quote.pdf "$SIGNED_URL"Do not let your HTTP client follow the redirect automatically with the key attached: curl -L, and many libraries, would send your X-API-Key to the storage host too. Read the Location header, then fetch it without the key. The signed link works for anyone who has it until it expires, so do not log it or pass it on.
Webhooks send an HTTP POST to your URL when something happens in your business. Each webhook watches one kind of resource and the events you pick for it.
Webhooks are set up in the dashboard: Integrations → Developer → Custom webhooks. That is also where you find each webhook's signing secret, a sample payload for every event, and its last 100 deliveries.
| Event | Description |
|---|---|
task.created · task.updated · task.deleted | A task was created, changed or deleted - by the AI agent or by someone on your team. |
calendar_event.created · calendar_event.updated · calendar_event.deleted | A meeting was booked, moved or cancelled. |
collection_row.created · collection_row.updated · collection_row.deleted | A row was added, changed or removed in the one table the webhook watches. Cells arrive under data.values, keyed by your column keys. |
template_reply.received | Someone tapped a quick-reply button on a WhatsApp template you sent. Carries the reply text, their phone number and the button payload. |
client.created | A new client was added: a first message on any channel, a lead form, this API or the dashboard. Carries clientId, fullName, phoneNumber, email, source, tags, platformUserId, isActive and createdAt. CSV imports are not sent. Rarely the same client can arrive twice, so de-duplicate on data.clientId. |
Unless you set a payload template, every delivery is a JSON object with these fields:
| Field | Type | Always present | Description |
|---|---|---|---|
event | string | Yes | The event type, e.g. "task.updated". |
businessId | number | Yes | Your business id. |
deliveryId | string (UUID) | Yes | Unique per delivery. Store it to ignore a delivery you have already processed. |
timestamp | string (ISO 8601) | Yes | When the delivery was sent, in UTC. |
data | object | Yes | The resource itself: the task, calendar event, table row, quick reply or new client, including the client it belongs to where there is one. |
changes | object | No | On *.updated events only: each changed field with its before and after value. |
{
"event": "task.updated",
"businessId": 42,
"deliveryId": "3f1c2a9e-8b7d-4c51-9a0e-2d6f4b8e1c07",
"timestamp": "2026-10-02T17:30:00.000Z",
"data": {
"taskId": 1234,
"businessId": 42,
"clientId": 88,
"client": {
"id": 88,
"fullName": "Dana Levi",
"phoneNumber": "+972501234567",
"email": "dana@example.com"
},
"title": "Call back about the quote",
"content": "Asked for a price on the premium package.",
"priority": 2,
"dueDate": "2026-10-04T09:00:00.000Z"
},
"changes": {
"priority": { "before": 1, "after": 2 }
}
}The example is shortened. The dashboard shows the full sample for every event, built from your own tables for collection_row events.
Every delivery is sent with Content-Type: application/json, any custom headers you added, and these headers:
| Header | Description |
|---|---|
X-Webhook-Event | The event type. Same value as event in the body. |
X-Webhook-Delivery | The delivery id. Same value as deliveryId in the body. |
X-Webhook-Signature | sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the webhook secret. |
User-Agent | Always CrimboWebhooks/1.0. |
Anyone who knows your URL can POST to it, so check the signature before trusting a delivery. Compute an HMAC-SHA256 of the raw request body using the webhook secret, hex-encode it, prefix it with sha256=, and compare it to X-Webhook-Signature with a constant-time comparison. Use the body exactly as received: parsing and re-serialising the JSON changes the bytes and breaks the match.
A valid signature proves we sent the body, not that it is being sent for the first time. To stop a captured delivery from being replayed, reject any whose timestamp is more than 5 minutes old, and remember each deliveryId you have processed so a repeat is ignored. Both values are inside the signed body, so they cannot be altered.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.CRIMBO_WEBHOOK_SECRET;
if (!SECRET) throw new Error('CRIMBO_WEBHOOK_SECRET is not set');
const MAX_AGE_MS = 5 * 60 * 1000;
// express.raw keeps the body as bytes: the signature covers them exactly as sent.
app.post('/webhooks/crimbo', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
if (!Buffer.isBuffer(req.body)) return res.status(400).end(); // not JSON: not from us
const expected = Buffer.from(
'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex')
);
const received = Buffer.from(req.get('X-Webhook-Signature') || '');
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
const ageMs = Date.now() - Date.parse(event.timestamp);
if (!(Math.abs(ageMs) <= MAX_AGE_MS)) {
return res.status(400).end(); // missing, too old or in the future: possibly replayed
}
// One atomic step, e.g. INSERT into a table with a unique deliveryId column.
// A separate "check, then save" lets two copies through at the same moment.
if (!(await recordDelivery(event.deliveryId))) {
return res.status(200).end(); // duplicate: acknowledge, do nothing
}
await enqueue(event); // your job queue; do the real work there
res.status(200).end(); // answer within 5 seconds
});import hashlib, hmac, json, os
from datetime import datetime, timedelta, timezone
from flask import Flask, request, abort
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024
SECRET = os.environ["CRIMBO_WEBHOOK_SECRET"].encode()
MAX_AGE = timedelta(minutes=5)
@app.post("/webhooks/crimbo")
def crimbo_webhook():
body = request.get_data()
expected = b"sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest().encode()
received = request.headers.get("X-Webhook-Signature", "").encode()
if not hmac.compare_digest(received, expected):
abort(401)
try:
event = json.loads(body)
sent_at = datetime.fromisoformat(event["timestamp"].replace("Z", "+00:00"))
age = abs(datetime.now(timezone.utc) - sent_at)
except (ValueError, KeyError, TypeError, AttributeError):
abort(400) # malformed, or a timestamp without a timezone
if age > MAX_AGE:
abort(400) # too old or in the future: possibly replayed
# One atomic step, e.g. INSERT into a table with a unique deliveryId column.
if not record_delivery(event["deliveryId"]):
return "", 200 # duplicate: acknowledge, do nothing
enqueue(event) # your job queue; do the real work there
return "", 200 # answer within 5 secondsEach event is sent once. Any 2xx response within 5 seconds counts as delivered. A timeout, a non-2xx response or an unreachable URL is recorded as a failure in the delivery history, and the event is not retried - so answer quickly and do the real work in the background. Redirects are not followed either: a 3xx response counts as a failure, so register the final URL.
Deliveries are not guaranteed to arrive in order. Use timestamp to tell which change is newer, and deliveryId to skip anything you have already handled.
Saving a webhook and "Send test event" both deliver synthetic data marked with data._test: true, through the same headers, template and signature as a real event. Ignore those on your side if you do not want test rows.
To post straight into another service such as Monday or Notion, give the webhook a payload template: the JSON you want sent, with placeholders for our values. {{path}} inserts a value as an escaped string, for use inside quotes. {{{path}}} inserts it as-is, for numbers, booleans and whole objects. Paths start from event, timestamp, businessId, deliveryId, data and changes.
{
"name": "{{data.title}}",
"priority": {{{data.priority}}},
"client_phone": "{{data.client.phoneNumber}}",
"source_event": "{{event}}",
"deliveryId": "{{deliveryId}}",
"timestamp": "{{timestamp}}"
}The signature covers the rendered template, so verification works the same way with or without one. Keep {{deliveryId}} and {{timestamp}} in your template, as in the example: the X-Webhook-Delivery header is not signed, so without them in the body you cannot check for replays.
Errors return a JSON body with stable error and code fields plus a human-readable error message. The HTTP status reflects the category.
{
"error": "Invalid or revoked API key.",
"code": "API_KEY_INVALID"
}| Status | Code | Description |
|---|---|---|
| 401 | API_KEY_MISSING | No X-API-Key header was sent. |
| 401 | API_KEY_INVALID | The key is unknown, revoked, or malformed. |
| 403 | API_KEY_SCOPE_MISSING | The key is valid but lacks the scope this endpoint needs. The response names it in `required_scope`. |
| 400 | TEMPLATE_NAME_REQUIRED | template_name is missing or empty. |
| 400 | LANGUAGE_REQUIRED | language is missing or empty. |
| 400 | PHONE_NUMBER_REQUIRED | phone_number is missing or empty. |
| 400 | PHONE_NUMBER_INVALID | The phone number could not be normalised to a valid form. |
| 400 | PARAMETERS_INVALID | parameters was supplied but is not a JSON array. |
| 400 | HEADER_MEDIA_INVALID | header_media is not an object, its type is not image, video or document, or its url is not a valid https link. |
| 400 | BUTTON_PARAMETERS_INVALID | button_parameters is not an array of { index, value } objects, an index is outside 0-9 or repeated, a value is empty, not a string, number or boolean, or longer than 2000 characters, or a type is not "url" or "quick_reply". |
| 403 | CLIENT_BLOCKED | The recipient is blocked by your business. |
| 424 | WA_NOT_CONNECTED | Your business has not finished connecting WhatsApp. |
| 400 | ACTION_TYPE_REQUIRED | actionType is missing or empty on a /send-action request. |
| 400 | ACTION_TYPE_UNKNOWN | No active configuration exists for that actionType. Your partner adds it on their partner page. |
| 400 | ACTION_FIELD_MISSING | A field the action maps is absent or empty in the payload. The response lists each one in `missing_fields`. |
| 400 | ACTION_FIELD_INVALID | A mapped field arrived but cannot be sent as-is - for example a value longer than WhatsApp accepts. The response lists each one in `invalid_fields` with the reason. |
| 400 | PARTNER_NOT_CONFIGURED | The business behind this API key is not managed by a partner, so it has no action configuration. Use /send-template. |
| 400 | RECIPIENT_REQUIRED | recipient is missing or empty on a /send-action request. |
| 400 | RECIPIENT_INVALID | The recipient number could not be normalised to a valid form. |
| 400 | TEMPLATE_SEND_FAILED | Meta rejected the send. The response includes Meta's error code (`meta_code`) and trace id (`meta_trace_id`) when available. |
| 400 | INVALID_PATH_PARAM | A collection id, row id or column key in a file download URL is malformed. |
| 400 | NOT_A_FILE_COLUMN | The column in a file download URL is not a file column. |
| 404 | ROW_NOT_FOUND | No such row in that collection. |
| 404 | CELL_EMPTY | That cell holds no file. |
| 404 | FILE_UNAVAILABLE | The file this cell points at is no longer stored. |
| 500 | FILE_LINK_FAILED | The file exists but a download link could not be made. Retry shortly. |
| 400 | TAGS_INVALID | tags is missing, is not an array, contains an entry that is not a non-empty string, or exceeds the limits of 100 names per request and 100 characters per name. |
| 404 | CLIENT_NOT_FOUND | No client with that phone number exists for your business. Client tag calls never create clients. |
| 404 | TAG_NOT_FOUND | One or more tag names do not exist in your business. Nothing was changed, and the response lists the unknown names in `missing_tags`. |
| 500 | CLIENT_TAGS_FAILED | A client tags request failed on our side. The change may or may not have been applied. Retrying is safe, because add, replace and remove are all idempotent. |