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

FieldRequiredDescription
fromyesOne of your numbers, E.164 (+17205550100)
toyesRecipient, E.164
textyes, unless media is setMessage body, up to 2,048 characters
medianoList of 1 to 10 public https URLs to send as MMS. We fetch them when sending, so they must be reachable without login.
referencenoYour 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.

KeywordsWhat happens
STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT, REVOKESender 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, UNSTOPSender is opted back in. We send a confirmation.
HELP, INFOWe 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." } }
StatusMeaning
400Bad request. Check error.code.
401Missing or invalid API key.
403number_not_allowed: the from number isn't enabled for messaging on your account. recipient_opted_out: the recipient texted STOP.
422The message was rejected. See error.message.
429Rate limited. Back off and retry.
502 / 503Temporary problem. Retry with backoff.