Ahoy Messaging API
Send SMS and MMS from your app. Get inbound replies and delivery receipts by webhook.
AuthenticationSend a messageWebhooksMediaSTOP and HELPVerifying webhooksErrorsOpenAPI spec
Authentication
Base URL: https://api.ahoy.tel
Send your API key as a bearer token on every request. Keep it server-side. Never ship it in browser or mobile code.
Authorization: Bearer ahk_your_key_here
Send a message
POST /v1/messages
| Field | Required | Description |
|---|---|---|
from | yes | One of your numbers, E.164 (+17205550100) |
to | yes | Recipient, E.164 |
text | yes, unless media is set | Message body, up to 2,048 characters |
media | no | List of 1 to 10 public https URLs to send as MMS. We fetch them when sending, so they must be reachable without login. |
reference | no | Your own ID for the message, up to 128 characters. Comes back on its delivery receipts. No personal data. |
curl -X POST https://api.ahoy.tel/v1/messages \
-H "Authorization: Bearer $AHOY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "+17205550100",
"to": "+13035550123",
"text": "Hi Sam, your order is ready for pickup.",
"reference": "go-msg-88231"
}'
Response 202 Accepted:
{
"id": "1761063721845xk3n2t7qa4bvq9mz",
"type": "sms",
"from": "+17205550100",
"to": "+13035550123",
"status": "accepted",
"segmentCount": 1,
"reference": "go-msg-88231",
"createdAt": "2026-10-21T14:02:01.845Z"
}
MMS:
{
"from": "+17205550100",
"to": "+13035550123",
"text": "Here's your receipt",
"media": ["https://cdn.example.com/receipts/88231.jpg"]
}
type in the response is sms or mms. Store the id. Delivery receipts for this message carry the same id.
Webhooks
We POST one JSON event per request to the webhook URL on your account. Respond with any 2xx within 8 seconds. Anything else, or a timeout, gets retried with backoff for about 90 minutes.
Delivery is at-least-once, so you may occasionally see the same event twice. Use the event id to ignore duplicates.
Inbound reply: message.received
{
"id": "evt_5b0e1d7c9a2f43e8b61c0d4a",
"type": "message.received",
"createdAt": "2026-10-21T14:05:12.402Z",
"data": {
"id": "1761063912402q8wz5n1hx0crk3tb",
"type": "sms",
"from": "+13035550123",
"to": "+17205550100",
"text": "Great, I'll be there at 3.",
"segmentCount": 1,
"receivedAt": "2026-10-21T14:05:12.000Z"
}
}
Inbound MMS adds a media list. See Media. If the message is a STOP, START or HELP keyword, data.keyword is stop, start or help. See STOP and HELP.
Delivery receipts: message.sent, message.delivered, message.failed
Every outbound message ends in exactly one of message.delivered or message.failed. message.sent means it was handed to the carrier and may arrive before either.
{
"id": "evt_9f31c2a07be54d1e8c2a6b13",
"type": "message.delivered",
"createdAt": "2026-10-21T14:02:03.118Z",
"data": {
"id": "1761063721845xk3n2t7qa4bvq9mz",
"from": "+17205550100",
"to": "+13035550123",
"status": "delivered",
"segmentCount": 1,
"reference": "go-msg-88231"
}
}
Receipts for the automatic STOP, START and HELP replies we send on your behalf include "autoReply": "stop" (or start, help) and no reference.
Failures add errorCode and errorDescription:
{
"id": "evt_1c7d0e4b2a9f45c3b8e1d6a0",
"type": "message.failed",
"createdAt": "2026-10-21T14:02:04.551Z",
"data": {
"id": "1761063721845xk3n2t7qa4bvq9mz",
"from": "+17205550100",
"to": "+13035550123",
"status": "failed",
"segmentCount": 1,
"reference": "go-msg-88231",
"errorCode": 4432,
"errorDescription": "forbidden to country"
}
}
Test event: webhook.test
Sent when we test your endpoint. Return 2xx and ignore it.
Media
Inbound MMS events include each attachment:
"media": [
{
"id": "1761063912402q8wz5n1hx0crk3tb-0.jpg",
"contentType": "image/jpeg",
"size": 148213,
"url": "https://api.ahoy.tel/v1/media/1761063912402q8wz5n1hx0crk3tb-0.jpg"
}
]
Download with GET on the url using your API key. You'll get a 302 redirect to a link that works for 5 minutes. Most HTTP clients follow it automatically. Media is kept for 30 days.
curl -L -H "Authorization: Bearer $AHOY_API_KEY" -o photo.jpg \
https://api.ahoy.tel/v1/media/1761063912402q8wz5n1hx0crk3tb-0.jpg
STOP and HELP
We handle carrier-required keywords on your numbers automatically. A keyword counts when it's the whole message, ignoring case and punctuation.
| Keywords | What happens |
|---|---|
| STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT, REVOKE | Sender is opted out of all your numbers. We send the opt-out confirmation. Sends to them return 403 recipient_opted_out until they text START. |
| START, UNSTOP | Sender is opted back in. We send a confirmation. |
| HELP, INFO | We reply with your help message. Opt-in status doesn't change. |
You still get every one of these as a message.received webhook with data.keyword set, so GO can mark the contact. Don't send your own confirmation, or they'll get two.
Verifying webhooks
Every webhook is signed with your signing secret (whsec_...). Headers:
X-Ahoy-Timestamp: 1761055381
X-Ahoy-Signature: t=1761055381,v1=5f2c9e...
To verify: compute HMAC-SHA256 of {timestamp}.{raw request body} with your signing secret, hex-encode it, and compare to v1 with a constant-time compare. Reject if the timestamp is more than 5 minutes old. Use the raw body bytes, not re-serialized JSON.
Node.js
const crypto = require("crypto");
function verifyAhoy(rawBody, sigHeader, secret) {
const parts = Object.fromEntries(sigHeader.split(",").map(p => p.split("=")));
const ts = Number(parts.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python
import hashlib, hmac, time
def verify_ahoy(raw_body: bytes, sig_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
ts = int(parts.get("t", 0))
if abs(time.time() - ts) > 300:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Errors
{ "error": { "code": "invalid_to", "message": "'to' must be a valid US number in E.164 format." } }
| Status | Meaning |
|---|---|
| 400 | Bad request. Check error.code. |
| 401 | Missing or invalid API key. |
| 403 | number_not_allowed: the from number isn't enabled for messaging on your account. recipient_opted_out: the recipient texted STOP. |
| 422 | The message was rejected. See error.message. |
| 429 | Rate limited. Back off and retry. |
| 502 / 503 | Temporary problem. Retry with backoff. |