Overview
The CampanaOne public API lets Zapier, n8n and your organisation's own systems:
- send alerts on any channel your organisation has enabled;
- keep contacts up to date, including custom contact fields;
- react to what happens in CampanaOne, through webhooks.
| Base URL | https://api.campanaone.com/public/api |
|---|---|
| Token URL | https://api.campanaone.com/public/api/oauth/token |
| Format | JSON in, JSON out. The token endpoint also accepts form-encoded bodies. |
| Errors | { "error": "...", "code": "..." }. The token endpoint uses the OAuth form { "error": "...", "error_description": "..." }. |
| Availability | API access is part of the Enterprise plan. |
In the examples below, {base} means the base URL. Use the API from servers only. Never put a client secret in a browser or mobile app.
Authentication: OAuth 2.0 client credentials
API clients
An organisation administrator creates API clients in CampanaOne under Settings › Integrations › API clients. Each client has:
| Setting | Notes |
|---|---|
| Name | For example "Zapier — HR sync". |
| Scopes | What the client may do. See the table below. |
| Lifetime | 90, 180 or 365 days (default 365). After that the client stops working until it is rotated. |
| IP allowlist | Optional. Up to 20 IPv4 or IPv6 addresses or CIDR ranges. Ranges wider than /8 (IPv4) or /16 (IPv6) are refused. Empty means any address. |
| Daily limit | Optional. Can be lower than the organisation's limit, but not higher. |
Creating a client shows its client ID (co_client_…) and client secret (co_secret_…) once. Copy them straight away.
| Scope | Allows |
|---|---|
alerts:read | GET /alerts, GET /alerts/{id}, GET /responses |
alerts:send | POST /alerts, GET /groups |
contacts:read | GET /contacts, GET /custom-fields, GET /groups |
contacts:write | POST /contacts |
webhooks | GET /webhooks, POST /webhooks, DELETE /webhooks/{id} |
GET /me needs no scope.
Getting an access token
Exchange the client ID and secret for an access token. Send the credentials with HTTP Basic auth (preferred) or in the body, but not both.
POST {base}/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=alerts:send contacts:read
{ "access_token": "co_at_…", "token_type": "Bearer", "expires_in": 3600,
"scope": "alerts:send contacts:read" }
- Lifetime: tokens last 1 hour. Reuse a token until it expires; don't request one per call.
- Scope: optional. It narrows the token to some of the client's scopes. Leave it out to get all of them.
- Limit: each client can get at most 50 tokens a day (UTC). The 51st request returns
429. - Body formats:
application/x-www-form-urlencodedor JSON, withclient_idandclient_secretin the body if Basic auth is not used.
Errors follow RFC 6749:
| Status | error | Why |
|---|---|---|
| 400 | invalid_request | Missing fields, an unreadable body, or credentials sent twice. |
| 400 | unsupported_grant_type | grant_type is not client_credentials. |
| 400 | invalid_scope | A scope the client does not have. |
| 400 | unauthorized_client | The caller's IP address is not on the allowlist, the organisation does not have API access, or the client's creator can no longer use it. |
| 401 | invalid_client | Missing or wrong ID or secret, or the client is revoked or expired. |
| 405 | invalid_request | Not a POST. |
| 429 | too_many_requests | 50 tokens already issued today, or too many failed sign-ins from this IP. Includes Retry-After. |
Calling the API
Authorization: Bearer co_at_…
A token stops working before its hour is up when:
- the client is rotated (all of its tokens end at once) or revoked;
- the client expires;
- a scope is removed from the client (the token keeps only the scopes the client still has);
- the client's creator is deactivated, or is no longer an administrator or initiator (
401,"code": "creator_no_access"); - the organisation loses API access (
403,"code": "api_access_disabled"); - the call comes from an address outside the allowlist (
403,"code": "ip_not_allowed").
A client acts as the person who created it: alerts are sent in that person's name, and recipients and new contacts are in that person's division.
Rotating and revoking
- Rotate issues a new secret, can change the lifetime, and ends every token issued with the old one. Rotating also brings an expired client back.
- Revoke is permanent. It ends every token and switches off the webhooks the client made.
Failed sign-in protection
After 20 failed sign-ins from one IP address in a 15-minute window, every request from that address is refused with 429 and a Retry-After header until the window ends, even with a valid token. Wrong secrets, unknown clients and invalid tokens all count.
Rate limits
- Organisation limit: each organisation has a daily call limit, 100 calls a day by default. Contact us to raise it.
- Client limit: an organisation administrator can set a lower limit on a client.
- Day: days run from midnight UTC.
- What counts: every authenticated call. Token requests have their own limit of 50 a day.
Every authenticated response has these headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1760313600 (Unix time of the next UTC midnight)
When the limit is reached, calls return 429 with "code": "rate_limited" and Retry-After in seconds.
Idempotency: retrying safely
POST /alerts, POST /contacts and POST /webhooks accept an Idempotency-Key header: any unique string up to 255 characters. Use a UUID.
Idempotency-Key: 6f1c2b8e-1d7a-4d4c-9e7b-3b2f9a0c1d55
- Repeat within 24 hours, same body: the first response is returned again, with
Idempotent-Replayed: true. Nothing is sent or changed a second time. - Same key, different body:
422,"code": "idempotency_key_reused". - Same key while the first request is still running:
409,"code": "idempotency_in_progress". - 4xx responses are not kept, so you can fix the request and send it again with the same key.
- Keys belong to one client and are forgotten after 24 hours.
POST /alerts. Without one, a client that retries after a timeout can send an alert twice.Test the connection
GET/me
Returns the organisation, the client and what it can do:
{
"organisation": { "name": "Acme", "namespace": "acme" },
"client": { "name": "Zapier", "client_id": "co_client_…" },
"scopes": ["alerts:send", "contacts:read"],
"daily_limit": 100,
"acting_as": { "name": "Ada Admin", "role": "org_admin" },
"channels": ["email", "sms"],
"features": { "two_way": true, "scheduling": true, "custom_fields": true },
"event_types": ["alert.sent", "alert.completed", "response.received", "delivery.failed"]
}
Alerts
POST/alerts alerts:send
Sends an alert.
{
"name": "Office closure",
"message": "The London office is closed today. Work from home.",
"channels": ["sms", "email"],
"audience": "all",
"response_options": ["Safe", "Need help"],
"schedule_at": "2026-11-01T08:00:00Z"
}
| Field | Required | Notes |
|---|---|---|
message | yes | Up to 4,000 characters. |
channels | yes | An array, or text separated by commas. Any of email, sms, voice, whatsapp, push, desktop_alert that the organisation has enabled. |
audience / send_to_all | one audience field | "audience": "all" or "send_to_all": true sends to everyone in the client creator's division. |
group_ids | one audience field | Group ids from GET /groups. |
contact_ids | one audience field | Contact ids. The contacts must be in the creator's division. |
customer_unique_ids | one audience field | Your own ids for the contacts. Same division rule as contact_ids. |
name | no | Defaults to the name the app would give the alert. |
message_per_channel | no | For example { "sms": "short text" }, which overrides message on that channel. |
response_options | no | Makes the alert two-way. Up to 5 options of 50 characters each. Needs two-way messaging. |
schedule_at | no | ISO 8601. Needs scheduling. A time in the past, or within the next minute, sends now. |
The audience fields can be combined. An unknown id fails the whole request with 400, and nothing is sent. Returns 201 { "alert": Alert }.
GET/alerts alerts:read
Lists alerts, newest first. Query parameters:
status: one ofdraft,scheduled,in_progress,completed,failed;since: an ISO time; returns alerts created after it;limit: default 25, maximum 100.
Returns { "alerts": [Alert] }.
GET/alerts/{id} alerts:read
Returns the alert together with answers: [{ "option", "count" }]. Each person counts once, by their latest answer.
The Alert object
This is also the data of the alert.sent and alert.completed events.
{
"id": "uuid", "name": "Office closure", "status": "in_progress", "terminated": false,
"channels": ["sms", "email"], "message": "…", "two_way": true,
"response_options": ["Safe", "Need help"],
"scheduled_for": null, "sent_at": "…", "completed_at": null, "created_at": "…",
"stats": { "recipients": 120, "deliveries": 240, "pending": 30,
"delivered": 190, "failed": 5, "responded": 15 }
}
Responses
GET/responses alerts:read
Answers to two-way alerts, newest first. Query parameters:
alert_id;since: an ISO time; returns answers given after it;limit: default 25, maximum 100.
Returns { "responses": [Response] }. The Response object is also the data of the response.received event:
{
"id": "uuid",
"alert": { "id": "uuid", "name": "Office closure" },
"contact": { "id": "uuid", "platform_id": "…", "customer_unique_id": "EMP001",
"first_name": "Sam", "last_name": "Lee",
"email": "…", "mobile": "+44…", "phone": "+44…" },
"option": "Safe", "channel": "sms", "via": "sms", "responded_at": "…"
}
Contacts
GET/contacts contacts:read
Finds contacts. Search with any of these query parameters:
customer_unique_id;email: matches any of the three email fields, ignoring case;phone: matches any phone or SMS field, in E.164 format;limit.
Returns { "contacts": [Contact] }, where each contact has:
idandplatform_id;- every standard field:
first_name,last_name,job_title,company,language,timezone,phone_1–phone_3,sms_1–sms_3,email_1–email_3,address_line_1,city,state_region,zip_post_code,country; custom_fields: every custom field by name,nullwhere the contact has no value;opted_out,created_atandupdated_at.
POST/contacts contacts:write
Adds or updates a contact.
- Update or add: if
customer_unique_idmatches an existing contact, that contact is updated (200,"created": false). Otherwise a contact is added (201,"created": true). - Partial updates: an update changes only the fields you send. Send a field as
nullor""to clear it. - Validation: the same rules as the app and CSV import, so phone numbers must be in E.164 format. If anything is invalid, nothing is written.
- SMS consent: send
sms_1_consent: true(orsms_2_consent,sms_3_consent) with an SMS number to record consent. If you change an SMS number without sending its consent, consent for that number is cleared. Consent on numbers you did not touch is kept. - Groups:
group_idsadds the contact to those static groups. The response says how many it was added to, ingroups_added. - Location: new contacts are placed in the client creator's division.
Custom contact fields
Needs the organisation's Custom contact fields feature.
GET/custom-fields contacts:read
{ "custom_fields": [
{ "id": "uuid", "name": "First Aider", "type": "boolean", "options": null },
{ "id": "uuid", "name": "Shift", "type": "multiple_choice", "options": ["Day", "Night"] },
{ "id": "uuid", "name": "Badge", "type": "text", "options": null }
] }
Write them in POST /contacts as an object keyed by field name (case does not matter) or field id:
{ "customer_unique_id": "EMP001",
"custom_fields": { "First Aider": true, "Shift": "night", "Badge": "B-12" } }
| Type | Accepts |
|---|---|
boolean, checkbox | true/false, or "yes"/"no", "1"/"0", "checked"/"unchecked", "on"/"off" |
multiple_choice | One of the options, case ignored. It is stored with the option's own spelling. |
text | Up to 2,000 characters. |
nullor""removes a value. Fields you don't send are left as they are.- An unknown field name or an invalid value fails the whole request with
400, and nothing is written.
Groups
GET/groups contacts:read or alerts:send
Returns { "groups": [{ "id", "name", "description", "type": "static" | "dynamic" }] }.
Webhook subscriptions
For REST hooks in Zapier and n8n. An administrator can also add webhooks on the Integrations page in CampanaOne.
POST/webhooks webhooks
Body: { "url": "https://…", "events": ["response.received"], "description": "…" }. A single event is also accepted in place of events.
Returns 201 { "id", "url", "events", "is_active", "created_at", "secret" }. An organisation can have at most 25 active webhooks.
GET/webhooks webhooks
Lists the subscriptions this client made.
DELETE/webhooks/{id} webhooks
Removes a subscription that this client made. Subscriptions added on the Integrations page can only be removed there.
Webhook events
| Event | When it is sent | data |
|---|---|---|
alert.sent | An alert starts sending: immediately, or at its scheduled time. | Alert |
alert.completed | An alert finishes: its timer ends, it is terminated, or resending finishes. | Alert |
response.received | Someone answers a two-way alert, on any channel. | Response |
delivery.failed | A message could not be delivered on a channel. | see below |
ping | Send test is pressed on the Integrations page. | { "message": "…" } |
The delivery.failed data:
{
"id": "delivery uuid",
"alert": { "id", "name" }, "contact": { … same as Response … },
"channel": "sms", "address": "+44…", "status": "failed",
"attempt": 1, "is_resend": false, "reason": "Carrier rejected", "sent_at": "…"
}
Request format
Every event is a POST with a JSON body:
{ "id": "event uuid", "event": "response.received", "occurred_at": "…", "data": { … } }
Headers:
Content-Type: application/json
User-Agent: CampanaOne-Webhooks/1.0
X-CampanaOne-Event: response.received
X-CampanaOne-Delivery: <event id>
X-CampanaOne-Signature: t=1760000000,v1=<hex>
Verifying the signature
v1 is the hex HMAC-SHA256 of "<t>.<raw body>", keyed with the webhook's signing secret (whsec_…). The secret is shown once, when the webhook is added.
const crypto = require('crypto');
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 || '');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
return fresh && given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(expected), given);
}
Use the raw request body, not a re-serialised copy.
Delivery, retries and switching off
- Timing: expect up to about a minute between the event and its delivery.
- Success: any
2xxresponse within 10 seconds. - Retries: anything else is retried after 1, 2, 4, 8 … minutes, for up to 8 attempts in all.
- Duplicates: an event can occasionally arrive twice. Use
idto remove duplicates. 410 Gone: a reply of410switches the webhook off straight away.- Repeated failures: after 50 failures in a row, the webhook is switched off.
- Other ways a webhook stops: a webhook made through the API also stops when its client is revoked or expires, loses the
webhooksscope, or its creator loses access. All webhooks stop when the organisation loses API access. - Allowed URLs: public
httpsorhttpaddresses only. Private, loopback and link-local addresses are refused. - History: event history is kept for 14 days.
Connecting Zapier and n8n
Both tools support OAuth 2.0 client credentials. Create one API client per connection, with only the scopes it needs.
n8n
- Create a credential of type OAuth2 API:
- Grant Type: Client Credentials
- Access Token URL:
https://api.campanaone.com/public/api/oauth/token - Client ID / Client Secret: from the Integrations page
- Scope: leave empty for all of the client's scopes, or list some separated by spaces
- Authentication: Header (Basic auth)
- Actions: an HTTP Request node with Authentication → Generic credential → OAuth2 API, URL
{base}/alertsand so on. Add anIdempotency-Keyheader, for example{{ $execution.id }}-{{ $runIndex }}. - Triggers: a Webhook node. Add its production URL on the Integrations page, or subscribe it with
POST /webhooks. - Checking the signature: do it in a Code node, using the function above.
Zapier
Until the CampanaOne Zapier app is available, use Webhooks by Zapier in two steps, because it does not fetch OAuth tokens on its own:
- Webhooks by Zapier → POST to
{base}/oauth/tokenwith form datagrant_type=client_credentials,client_idandclient_secret. - Webhooks by Zapier → Custom Request to
{base}/alertsor{base}/contacts, with the headersAuthorization: Bearer {{access_token from step 1}}andIdempotency-Key: {{zap_meta_id}}.
For triggers, use Webhooks by Zapier → Catch Hook and add the Catch Hook URL under Settings › Integrations › Webhooks. Polling GET /responses?since=… also works.
Security
- Client secrets and access tokens are stored only as keyed hashes, never in clear text. A copy of the database cannot be used to sign in.
- Webhook signing secrets are encrypted with a managed encryption key that rotates every year.
- All traffic uses HTTPS (TLS 1.2 or later).
- See our Security & Compliance page for more.
Need help with the API?
Email info@caraveliq.com with your organisation's namespace and the client name, and we'll get back to you.
Contact us