Public API
Build on Senpede.
A REST API for sending messages and campaigns, managing contacts, and receiving signed delivery and opt-out events from your Senpede organization. JSON over HTTPS, versioned, rate-limited, and authenticated with an API key you control.
Base URL
https://api.senpede.com
Version
/api/v1 · 1.3.1
Format
application/json
Authentication
Every request needs an API key, sent as a bearer token:
Generate a key from your organization's Settings → API Keys page (you'll need a Senpede account). The full key is shown once, at creation, then only a hash is stored, so keep it somewhere safe.
A key acts as itself, not as the person who created it, so it keeps working when people leave your team. Keys can expire, can be limited to your servers' IP addresses, and can be rotated: the replacement is issued immediately and the old key keeps working for an overlap you choose, so you can deploy without downtime. Keys that can send messages can only be issued by members with a verified email address.
OAuth 2.0 client credentials
Prefer short-lived tokens? Create an OAuth client instead of a key. Exchange its client ID and secret for an access token that lasts an hour, and send that token exactly as you would a key. Disabling or revoking the client stops its tokens straight away.
curl -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=client_credentials https://api.senpede.com/api/v1/oauth/token
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600, "scope": "…" }Test mode
Choose Test when you create a key and you get a sen_test_… key for a separate sandbox. It calls the same URLs, but nothing is sent: messages are captured, and delivery reports, replies and opt-outs are simulated. The recipient picks the outcome. For example, a number ending in 0003 replies and you+optout@example.com opts out. Sandbox webhooks carry "livemode": false.
Each key or client is scoped to exactly what it's allowed to do:
| contacts:read | List and fetch contacts. |
| contacts:write | Create, update and archive contacts, and request erasure (includes read access). |
| contacts:import | Import up to 10,000 contacts at a time in the background. |
| consent:write | Record consent and withdrawals you captured, with their lawful basis and evidence. |
| audiences:read | List audiences and their members. |
| audiences:write | Create and delete static audiences and manage their members. |
| templates:read | List message templates and the channels to send them with. |
| campaigns:read | List and fetch campaigns. |
| campaigns:write | Create and edit draft campaigns and broadcasts, and submit them for approval. |
| campaigns:send | Schedule, pause, resume and cancel campaigns a team member has approved. |
| messages:send | Send one transactional message to one contact and read its status. |
| webhooks:manage | Register webhook endpoints, read their delivery log and replay deliveries. |
| usage:read | Read your allowances this billing period and how much of each is used. |
| journeys:read | List journeys and read their enrollments. |
| journeys:enroll | Enroll contacts in published journeys and take them out again. |
| organizations:manage | Partners only: create a Senpede organization for each of your customers, then act in it with the Senpede-Organization header. |
Node.js SDK
@senpede/node is generated from the OpenAPI contract, so its types always match the API. It retries network errors, 429 and 5xx with backoff, adds an idempotency key to every write, refreshes OAuth tokens, verifies webhook signatures, and warns when you call something deprecated. It has no runtime dependencies and needs Node.js 20 or later.
npm install @senpede/nodeimport { Senpede } from '@senpede/node';
const senpede = new Senpede({ apiKey: process.env.SENPEDE_API_KEY! });
// Create or update a contact by your own id for it.
const { contact } = await senpede.contacts.upsertByRef('crm-1001', {
fullName: 'Ama Mensah',
phone: '+233241234567',
});
// Send one transactional message. Retries reuse the same idempotency key.
const message = await senpede.messages.send(
{ to: { contactId: contact.id }, channelId: 'chan_…', message: 'Your order has shipped.' },
{ idempotencyKey: 'order-1001-shipped' },
);
// Go through every contact, page by page.
for await (const c of senpede.paginate(senpede.contacts.list, { status: 'ACTIVE' })) {
console.log(c.fullName);
}import { verifyWebhook } from '@senpede/node';
// Pass the raw body, exactly as it arrived.
const event = await verifyWebhook({
payload: rawBody,
headers: request.headers,
secret: process.env.SENPEDE_WEBHOOK_SECRET!,
});
if (event.type === 'message.replied') { /* … */ }Use oauth: { clientId, clientSecret } instead of apiKey for an OAuth client, and a sen_test_… key for test mode. Errors are SenpedeApiError, with the API's code and requestId.
Rate limits and retries
Limits apply per key, not per IP address, so all your servers can share one key's allowance. Every key defaults to 120 requests per minute, and can be configured up to 6,000 per minute when you create it. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds). Requests over the limit get a 429 with a Retry-After header — wait that many seconds and retry.
Safe retries
Send an Idempotency-Key header (any unique value up to 255 characters, such as a UUID) on every POST, PATCH or DELETE. If a request times out, retry it with the same key: for 24 hours Senpede returns the first response instead of running the request again, marked with Idempotent-Replayed: true, so a retried send never messages a customer twice.
- The same key with a different request body returns
422 IDEMPOTENCY_KEY_REUSED. - A retry while the first request is still running returns
409 IDEMPOTENCY_REQUEST_IN_PROGRESS; retry shortly. - If the first request failed, the key is released and you can retry with it.
Endpoints
Every request and response body, error and header is described in the OpenAPI contract, openapi.json. Import it into Postman or Insomnia, or generate a typed client from it. It is generated from the API itself, so it always matches what the API does.
Contacts
Create a contact. fullName is required; everything else is optional.
curl https://api.senpede.com/api/v1/public/contacts \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"fullName": "Ama Boateng",
"email": "ama@example.com",
"phone": "+233241234567",
"tags": [
"newsletter"
]
}'List contacts, with optional search.
curl https://api.senpede.com/api/v1/public/contacts?page=1&pageSize=20&q=ama \
-H "Authorization: Bearer sen_your_api_key"Keep contacts in step with your own system by your id for them. The contact is matched by externalRef, then email, and created if none matches, answering 201, or updated, answering 200. Fields you leave out are kept. PATCH /public/contacts/{id} updates by Senpede id, DELETE archives, and POST …/erasure schedules removal of the contact's personal data.
curl -X PUT https://api.senpede.com/api/v1/public/contacts/by-ref/customer-1001 \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"fullName": "Ama Boateng",
"email": "ama@example.com",
"tags": [
"gold"
]
}'Import up to 10,000 contacts at once. The rows are imported in the background: poll GET /public/contacts/imports/{id} or subscribe to contact_import.completed. Rows that can't be imported are skipped and listed with the reason. You confirm the contacts were collected lawfully with attestedAcceptableUse. Needs contacts:import.
curl https://api.senpede.com/api/v1/public/contacts/imports \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"mode": "UPSERT",
"attestedAcceptableUse": true,
"rows": [
{
"externalRef": "customer-1001",
"fullName": "Ama Boateng"
},
{
"externalRef": "customer-1002",
"fullName": "Kofi Mensah",
"phone": "+233201234567"
}
]
}'Consent
If your system already collects consent, hand it over with the lawful basis, a pointer to your evidence and when you captured it. Marketing sends need it; other messages don't. Up to 500 per call, each with its own result. Needs consent:write.
curl https://api.senpede.com/api/v1/public/consent-assertions \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"assertions": [
{
"contact": {
"externalRef": "customer-1001"
},
"channel": "SMS",
"purpose": "MARKETING",
"status": "GRANTED",
"lawfulBasis": "CONSENT",
"evidenceRef": "signup-form-881",
"capturedAt": "2026-09-20T10:15:00Z"
}
]
}'A contact's own “no” always wins. If they replied STOP or withdrew on the preference page after you captured consent, the result is conflict and nothing changes. Suppressions are never lifted: they are listed in blockedBy, and the contact stays unreachable on that channel. Check what applies with GET /public/contacts/{id}/consent. You can also send consent with a contact upsert or a broadcast recipient.
Audiences
Build static audiences to target campaigns. Create one, then add up to 500 contacts per call by Senpede id or your own externalRef. Audiences defined by criteria are managed in the dashboard and are read-only here.
curl https://api.senpede.com/api/v1/public/audiences/aud_…/members \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"externalRefs": [
"customer-1001",
"customer-1002"
]
}'Templates and channels
Find the ids to use in a campaign or message. GET /public/templates lists approved templates, optionally for one channelType; GET /public/channels lists your channels and their health. Channel credentials are never returned.
curl https://api.senpede.com/api/v1/public/templates?channelType=SMS \
-H "Authorization: Bearer sen_your_api_key"Single messages
Send one transactional message, such as a receipt or an OTP, to one contact. Identify the contact by contactId, your own externalRef, or email. Use an approved template or plain message text. The message is queued and sent in the background, and contacts who have opted out are skipped. Marketing content belongs in a campaign, where each recipient's marketing consent is checked.
curl https://api.senpede.com/api/v1/public/messages \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"to": {
"externalRef": "customer-1001"
},
"channelId": "chan_…",
"templateId": "tmpl_…",
"reference": "order_shipped"
}'Check a message's status: PENDING, SENT, FAILED or SKIPPED, with the reason in detail.
curl https://api.senpede.com/api/v1/public/messages/cntf_… \
-H "Authorization: Bearer sen_your_api_key"Broadcasts
Send one message to a list you supply, of up to 500 recipients per call, without syncing your whole customer base first. Each recipient is matched to an existing contact by contactId, then externalRef, then email, or created if none matches. Senpede builds an audience from them and submits a campaign for approval. The response lists how each recipient was matched. Needs campaigns:write and contacts:write.
curl https://api.senpede.com/api/v1/public/broadcasts \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Branch closure notice",
"type": "OPERATIONAL",
"channelId": "chan_…",
"message": "Our Osu branch is closed on Friday.",
"recipients": [
{
"externalRef": "customer-1001"
},
{
"externalRef": "customer-1002",
"fullName": "Kofi Mensah",
"phone": "+233201234567"
}
]
}'Campaigns
Create a draft against an existing audience and submit it for approval (campaigns:write). A member of your team approves it in the dashboard; no API key can approve a campaign. Once approved, schedule, pause, resume or cancel it (campaigns:send). Consent and your usage allowance are checked for every recipient when it runs.
curl https://api.senpede.com/api/v1/public/campaigns \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Spring offer",
"audienceId": "aud_…",
"channelId": "chan_…",
"message": "Hello {{CustomerName}}"
}'Then POST /public/campaigns/{id}/submit, and after approval /schedule, /pause, /resume or /cancel.
curl https://api.senpede.com/api/v1/public/campaigns/camp_…/schedule \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"scheduledAt": "2026-10-01T09:00:00Z"
}'List and inspect campaigns.
curl https://api.senpede.com/api/v1/public/campaigns?status=COMPLETED \
-H "Authorization: Bearer sen_your_api_key"Follow each message: when it was sent, delivered, read, replied to and clicked, with totals for the whole campaign. Recipients' addresses are never returned; match on contactId. Set trackLinks: true on a campaign to track links in SMS, WhatsApp and Telegram; email links are always tracked. Messages due outside the organization's send window stay PENDING until it opens; heldUntil says when.
curl https://api.senpede.com/api/v1/public/campaigns/camp_…/messages?status=SENT \
-H "Authorization: Bearer sen_your_api_key"Errors
Errors share one shape, with a stable code you can branch on and a requestId to reference if you need help:
{
"error": {
"code": "VALIDATION_ERROR",
"category": "VALIDATION",
"message": "Validation failed.",
"retryable": false,
"validationIssues": [
{ "field": "fullName", "message": "fullName is required." }
],
"requestId": "req_01hx..."
}
}Webhooks
Register an HTTPS endpoint and choose the events it receives. Senpede POSTs each event as JSON and keeps your records in step with deliveries, replies and opt-outs. The signing secret is returned once, when you register the endpoint or rotate its secret.
curl https://api.senpede.com/api/v1/public/webhook-endpoints \
-H "Authorization: Bearer sen_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/senpede/events",
"eventTypes": [
"message.delivered",
"message.replied",
"contact.opted_out"
]
}'| message.sent | A message was handed to the channel provider. |
| message.delivered | The provider confirmed delivery. |
| message.read | The recipient opened or read the message. |
| message.replied | The recipient replied. Sent for every reply, with the text if you turn that on in Settings. |
| message.clicked | The recipient first clicked a tracked link in the message. |
| message.failed | Delivery failed permanently. |
| message.skipped | Not sent: no consent, opted out, missing address or allowance used up. |
| contact.opted_out | The contact opted out (STOP reply, preference page, or recorded by your team). |
| consent.changed | A consent record was granted or withdrawn. |
| campaign.status_changed | A campaign moved to a new status. |
| contact_import.completed | A contact import finished, with its created, updated and skipped counts. |
| usage.quota_low | An allowance passed 75% or 90% of what your plan includes this billing period. |
| usage.quota_exhausted | An allowance reached what your plan includes this billing period. |
| journey.enrolled | A contact started a journey. |
| journey.completed | A contact reached the end of a journey. |
| journey.exited | A contact left a journey early, on request or because it was archived. |
Verifying a delivery
Each request carries X-Senpede-Event, X-Senpede-Delivery (unique per delivery; use it to ignore duplicates), X-Senpede-Timestamp (milliseconds) and X-Senpede-Signature. Compute an HMAC-SHA256 of timestamp + "." + raw body with your secret and compare it with the hex value after v1=. Reject timestamps more than five minutes old.
{
"eventId": "event_01j…",
"eventName": "senpede.message.delivered",
"eventVersion": "1.0",
"type": "message.delivered",
"occurredAt": "2026-09-26T10:15:00.000Z",
"organizationId": "org_01j…",
"payload": {
"messageId": "cmsg_01j…",
"source": "campaign",
"campaignId": "camp_01j…",
"contactId": "cust_01j…",
"channelId": "chan_01j…",
"status": "SENT",
"detail": null,
"trigger": null
}
}Retries
Answer with any 2xx within 10 seconds. Timeouts, network errors, 408, 429 and 5xx responses are retried up to 8 times over about 10 hours. Other responses, and deliveries that run out of retries, are kept in the endpoint's delivery log for 30 days, where you can replay them.
Versioning
/api/v1 doesn't break: new operations and fields arrive in minor versions of the contract, and anything removed is deprecated first, with Deprecation and Sunset headers and at least 180 days' notice. The contract is at version 1.3.1.
