Developers

CampanaOne API & Webhooks

Send alerts, keep contacts in sync and react to responses from your own systems, Zapier or n8n.

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 URLhttps://api.campanaone.com/public/api
Token URLhttps://api.campanaone.com/public/api/oauth/token
FormatJSON in, JSON out. The token endpoint also accepts form-encoded bodies.
Errors{ "error": "...", "code": "..." }. The token endpoint uses the OAuth form { "error": "...", "error_description": "..." }.
AvailabilityAPI 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:

SettingNotes
NameFor example "Zapier — HR sync".
ScopesWhat the client may do. See the table below.
Lifetime90, 180 or 365 days (default 365). After that the client stops working until it is rotated.
IP allowlistOptional. 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 limitOptional. 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.

ScopeAllows
alerts:readGET /alerts, GET /alerts/{id}, GET /responses
alerts:sendPOST /alerts, GET /groups
contacts:readGET /contacts, GET /custom-fields, GET /groups
contacts:writePOST /contacts
webhooksGET /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-urlencoded or JSON, with client_id and client_secret in the body if Basic auth is not used.

Errors follow RFC 6749:

StatuserrorWhy
400invalid_requestMissing fields, an unreadable body, or credentials sent twice.
400unsupported_grant_typegrant_type is not client_credentials.
400invalid_scopeA scope the client does not have.
400unauthorized_clientThe 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.
401invalid_clientMissing or wrong ID or secret, or the client is revoked or expired.
405invalid_requestNot a POST.
429too_many_requests50 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.
Always send a key with 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"
}
FieldRequiredNotes
messageyesUp to 4,000 characters.
channelsyesAn array, or text separated by commas. Any of email, sms, voice, whatsapp, push, desktop_alert that the organisation has enabled.
audience / send_to_allone audience field"audience": "all" or "send_to_all": true sends to everyone in the client creator's division.
group_idsone audience fieldGroup ids from GET /groups.
contact_idsone audience fieldContact ids. The contacts must be in the creator's division.
customer_unique_idsone audience fieldYour own ids for the contacts. Same division rule as contact_ids.
namenoDefaults to the name the app would give the alert.
message_per_channelnoFor example { "sms": "short text" }, which overrides message on that channel.
response_optionsnoMakes the alert two-way. Up to 5 options of 50 characters each. Needs two-way messaging.
schedule_atnoISO 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 of draft, 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:

  • id and platform_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, null where the contact has no value;
  • opted_out, created_at and updated_at.

POST/contacts contacts:write

Adds or updates a contact.

  • Update or add: if customer_unique_id matches 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 null or "" 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 (or sms_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_ids adds the contact to those static groups. The response says how many it was added to, in groups_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" } }
TypeAccepts
boolean, checkboxtrue/false, or "yes"/"no", "1"/"0", "checked"/"unchecked", "on"/"off"
multiple_choiceOne of the options, case ignored. It is stored with the option's own spelling.
textUp to 2,000 characters.
  • null or "" 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

EventWhen it is sentdata
alert.sentAn alert starts sending: immediately, or at its scheduled time.Alert
alert.completedAn alert finishes: its timer ends, it is terminated, or resending finishes.Alert
response.receivedSomeone answers a two-way alert, on any channel.Response
delivery.failedA message could not be delivered on a channel.see below
pingSend 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 2xx response 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 id to remove duplicates.
  • 410 Gone: a reply of 410 switches 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 webhooks scope, or its creator loses access. All webhooks stop when the organisation loses API access.
  • Allowed URLs: public https or http addresses 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

  1. 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)
  2. Actions: an HTTP Request node with Authentication → Generic credential → OAuth2 API, URL {base}/alerts and so on. Add an Idempotency-Key header, for example {{ $execution.id }}-{{ $runIndex }}.
  3. Triggers: a Webhook node. Add its production URL on the Integrations page, or subscribe it with POST /webhooks.
  4. 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:

  1. Webhooks by Zapier → POST to {base}/oauth/token with form data grant_type=client_credentials, client_id and client_secret.
  2. Webhooks by Zapier → Custom Request to {base}/alerts or {base}/contacts, with the headers Authorization: Bearer {{access_token from step 1}} and Idempotency-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.

This requests a token on every run, so it suits Zaps that run up to 50 times a day.

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