Download OpenAPI specification:
Read contacts and conversations, and reply to contacts who wrote first, from your own code or an AI agent.
Base URL: https://api.smartercontact.com/rest. Every resource is versioned on its own path (/contacts/v1).
Errors from the API come back as JSON: { "code": "...", "message": "..." }. A 429 also carries retryAfterSeconds in the body and a Retry-After header. The OAuth endpoints answer in the OAuth shape instead: { "error": "...", "error_description": "..." }.
Limits (defaults):
REPLY_REQUIRED, REPLY_TOO_SOON or BAD_REQUEST. A RATE_LIMITED send waits until the next UTC hour.GET /contacts/v1/{id} and the read_contact tool count against the same one.access_denied.Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds).
Before using the API, the account owner accepts the Developer Terms on the Developer Platform page. Until then every call returns 403 TERMS_NOT_ACCEPTED.
{- "id": "string",
- "status": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "phone": "string",
- "email": "string",
- "firstName": "string",
- "lastName": "string",
- "favorite": true,
- "dealClosed": true,
- "unsubscribed": true,
- "isOptIn": true,
- "fields": [
- {
- "fieldId": "string",
- "name": "string",
- "value": "string"
}
]
}Messages with one contact, newest first. Pass nextId and nextCreatedAt from the previous page to get the next one.
| id required | string The contact id. |
| limit | integer [ 1 .. 100 ] Default: 50 |
| nextId | string Cursor from the previous page. Requires |
| nextCreatedAt | string <date-time> Cursor from the previous page, an ISO date. |
{- "items": [
- {
- "id": "string",
- "direction": "string",
- "type": "string",
- "content": "string",
- "status": "string",
- "createdAt": "2019-08-24T14:15:22Z"
}
], - "hasNext": true,
- "nextId": "string",
- "nextCreatedAt": "2019-08-24T14:15:22Z"
}Sends an SMS reply. It cannot start a conversation: the contact must have written inside the reply window (7 days by default), and the reply must come at least 60 seconds after their last inbound message and after the account's last message to them (from any source: campaign, inbox, API or MCP). Nothing is queued: on 429 REPLY_TOO_SOON wait retryAfterSeconds and retry.
| id required | string The contact id. |
| content required | string non-empty The message text, not empty or only spaces. |
{- "content": "string"
}{- "id": "string",
- "direction": "string",
- "type": "string",
- "content": "string",
- "status": "string",
- "createdAt": "2019-08-24T14:15:22Z"
}Add endpoints on the Webhooks tab of the Developer Platform page (up to 16), each subscribed to the events it needs.
Delivery: a POST with a JSON body. Any non-2xx answer, or no answer, is retried: up to 3 attempts in total, with backoff from 1 to 60 seconds. Answer 2xx fast and process later.
Every body also carries email (the account's email) and, for a test sent from the Webhooks tab, "test": true. Tell the events apart by the body: an incoming SMS has "direction": "incoming" and no event field, a status update has "event": "message.status_updated".
Headers: the custom headers you set on the endpoint are sent with every delivery.
Test deliveries from the Webhooks tab are sent once, with no retry, a 10 second timeout and no redirects followed.
Signature: when the account has a signing secret (Webhooks tab, starts with whsec_), each delivery has the header
X-SC-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the secret>.
Verify against the raw body bytes, not re-serialized JSON. t is set when the delivery is first queued and stays the same on every retry, so allow for the retry backoff when you check its age.
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return fresh && typeof v1 === 'string' && v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts["v1"])
Flat body with no event field, kept as it is for existing integrations.
| direction | string Value: "incoming" |
| senderId | string The contact id. |
| senderPhoneNumber | string |
| recipientPhoneNumber | string |
| content | string |
| createdAt | string <date-time> |
string | |
| test | boolean |
{- "direction": "incoming",
- "senderId": "3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f",
- "senderPhoneNumber": "+15551234567",
- "recipientPhoneNumber": "+15557654321",
- "content": "Yes, still interested",
- "createdAt": "2026-10-01T15:04:05.000Z",
- "email": "owner@example.com"
}Only final states are sent: delivered or undelivered (a failed send is also reported as undelivered). reason explains an undelivered when known, otherwise null.
A test from the Webhooks tab has an id starting with evt_, random message_id and contact_id, source: null, and no from_number or to_number.
| id | string Event id, use it to dedupe retries. |
| event | string Value: "message.status_updated" |
| created_at | string <date-time> |
string | |
| test | boolean |
object |
{- "id": "9b1d6f0e-2c3a-4f5b-8d7e-6a5b4c3d2e1f",
- "event": "message.status_updated",
- "created_at": "2026-10-01T15:05:10.000Z",
- "email": "owner@example.com",
- "data": {
- "message_id": "7c6b5a49-3827-4165-9f8e-7d6c5b4a3928",
- "contact_id": "3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f",
- "status": "undelivered",
- "reason": "spam",
- "source": "api",
- "from_number": "+15557654321",
- "to_number": "+15551234567"
}
}