heartbit docs

Send and receive text messages from your own software, using the Android phones connected to your heartbit account.

heartbit has a REST API that speaks JSON. You send a message, heartbit hands it to one of your gateway phones, and the phone sends it through its SIM card. When a reply arrives, heartbit can notify your server with a webhook.

All API requests go to https://app.heartbit.biz/api/v1.

Connect a phone

Messages are sent by a gateway: an Android phone running the heartbit app. Before you use the API, connect at least one phone to your account. The gateway setup guide takes about five minutes.

Create an API key

  1. Sign in to your dashboard as an admin and open API.
  2. Create a key and choose what it may do: Send messages, Read messages and One-time codes. Give each integration only the permissions it needs.
  3. Copy the key. It starts with hb_live_ and is shown only once, so store it somewhere safe, such as an environment variable on your server.

Keep your key secret. Never put it in a website, a mobile app or a public code repository. If a key is exposed, revoke it on the API page and create a new one.

Make your first request

Send your key in the Authorization header of every request. This call checks that your key works and shows what it may do.

curl https://app.heartbit.biz/api/v1/whoami \
  -H "Authorization: Bearer $HEARTBIT_API_KEY"
const response = await fetch("https://app.heartbit.biz/api/v1/whoami", {
  headers: { Authorization: `Bearer ${process.env.HEARTBIT_API_KEY}` },
});
console.log(await response.json());
import os
import requests

response = requests.get(
    "https://app.heartbit.biz/api/v1/whoami",
    headers={"Authorization": f"Bearer {os.environ['HEARTBIT_API_KEY']}"},
)
print(response.json())

A successful response looks like this:

{
  "accountId": 42,
  "accountName": "Northwind Clinic",
  "keyName": "Booking system",
  "scopes": ["SendMessages", "ReadMessages"]
}

Send a message

POST/api/v1/messages

Queues a text message on one of your gateways. Needs the Send messages permission.

FieldDescription
torequiredThe recipient’s phone number, preferably in international format, such as +15551234567.
textrequiredThe message, up to 1,600 characters. Longer messages are sent in parts automatically.
gatewayIdWhich gateway to send from. Required if your account has more than one gateway. Find ids with List gateways.
simSlotWhich SIM to use on a dual-SIM phone: 0 for the first (default) or 1 for the second.
idempotencyKeyA unique value from your system, such as an order number. If you send the same request again with the same key, you get the original message back instead of sending a duplicate.
curl -X POST https://app.heartbit.biz/api/v1/messages \
  -H "Authorization: Bearer $HEARTBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+15551234567",
    "text": "Your appointment is tomorrow at 10am. Reply C to cancel.",
    "idempotencyKey": "booking-4821-reminder"
  }'
const response = await fetch("https://app.heartbit.biz/api/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.HEARTBIT_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    to: "+15551234567",
    text: "Your appointment is tomorrow at 10am. Reply C to cancel.",
    idempotencyKey: "booking-4821-reminder",
  }),
});
const message = await response.json();
response = requests.post(
    "https://app.heartbit.biz/api/v1/messages",
    headers={"Authorization": f"Bearer {os.environ['HEARTBIT_API_KEY']}"},
    json={
        "to": "+15551234567",
        "text": "Your appointment is tomorrow at 10am. Reply C to cancel.",
        "idempotencyKey": "booking-4821-reminder",
    },
)
message = response.json()

heartbit answers 202 Accepted as soon as the message is queued:

{
  "id": 1840,
  "status": "Pending",
  "to": "+15551234567",
  "gatewayId": "6f1c2b7e-3d4a-4f7e-9b1a-2c8d5e6f7a90",
  "simSlot": 0,
  "createdAt": "2026-09-10T14:03:22Z"
}

The phone then sends the message. To find out whether it was delivered, use Get a message or, better, subscribe to the message.delivered and message.failed webhooks.

Get a message

GET/api/v1/messages/{id}

Returns one message and its current status. Needs the Read messages permission.

curl https://app.heartbit.biz/api/v1/messages/1840 \
  -H "Authorization: Bearer $HEARTBIT_API_KEY"
const response = await fetch("https://app.heartbit.biz/api/v1/messages/1840", {
  headers: { Authorization: `Bearer ${process.env.HEARTBIT_API_KEY}` },
});
const message = await response.json();
response = requests.get(
    "https://app.heartbit.biz/api/v1/messages/1840",
    headers={"Authorization": f"Bearer {os.environ['HEARTBIT_API_KEY']}"},
)
message = response.json()
{
  "id": 1840,
  "direction": "outbound",
  "contact": "+15551234567",
  "gatewayNumber": "+15550134",
  "gatewayId": "6f1c2b7e-3d4a-4f7e-9b1a-2c8d5e6f7a90",
  "simSlot": 0,
  "text": "Your appointment is tomorrow at 10am. Reply C to cancel.",
  "status": "Sent",
  "createdAt": "2026-09-10T14:03:22Z",
  "receivedAt": "2026-09-10T14:03:22Z"
}

List messages

GET/api/v1/messages

Returns messages on your account, newest first. Needs the Read messages permission.

Query parameterDescription
contactOnly messages to or from this phone number.
directioninbound for received messages, or outbound for sent ones.
limitHow many messages to return, from 1 to 200. Default 50.
beforeReturn messages older than this message id. Use it to fetch the next page.
curl "https://app.heartbit.biz/api/v1/messages?direction=inbound&limit=50" \
  -H "Authorization: Bearer $HEARTBIT_API_KEY"
{
  "data": [
    { "id": 1852, "direction": "inbound", "contact": "+15551234567", "text": "C", "status": "Sent", ... }
  ],
  "hasMore": true,
  "nextBefore": 1803
}

When hasMore is true, request the next page by passing nextBefore as the before parameter.

List gateways

GET/api/v1/gateways

Returns the phones on your account, their numbers and which SIM cards can send. Needs the Read messages permission. If your account has more than one gateway, use this to find the gatewayId to send from.

curl https://app.heartbit.biz/api/v1/gateways \
  -H "Authorization: Bearer $HEARTBIT_API_KEY"
{
  "data": [
    {
      "id": "6f1c2b7e-3d4a-4f7e-9b1a-2c8d5e6f7a90",
      "name": "Front desk",
      "phoneNumber": "+15550134",
      "status": "online",
      "lastSeen": "2026-09-10T14:05:10Z",
      "sims": [
        { "slot": 0, "carrier": "T-Mobile", "phoneNumber": "+15550134", "canSend": true }
      ]
    }
  ]
}

Check your key

GET/api/v1/whoami

Returns the account your key belongs to and its permissions. Works with any key, and is a good way to check a new setup. See the example in Make your first request.

How verification codes work

Use verification codes to confirm that someone owns a phone number, for example when they sign up. heartbit creates a 6-digit code and texts it to them. They type it into your app, and you ask heartbit whether it’s correct. The code is never sent to your server, so you never need to store it.

Your key needs the One-time codes permission. For safety, codes follow fixed rules:

  • A code has 6 digits, works once, and expires after 5 minutes.
  • After 5 wrong attempts, the code is locked.
  • Asking for a new code replaces the previous one.
  • Each phone number can receive one code a minute, 3 an hour and 10 a day.
  • Codes are only sent to countries your account allows. You can change these and the message wording in Settings.

Send a code

POST/api/v1/verify/start
FieldDescription
torequiredThe phone number to verify.
gatewayIdWhich gateway to send from, if your account has more than one.
simSlotWhich SIM to use: 0 (default) or 1.
curl -X POST https://app.heartbit.biz/api/v1/verify/start \
  -H "Authorization: Bearer $HEARTBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+15551234567" }'
const response = await fetch("https://app.heartbit.biz/api/v1/verify/start", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.HEARTBIT_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ to: "+15551234567" }),
});
response = requests.post(
    "https://app.heartbit.biz/api/v1/verify/start",
    headers={"Authorization": f"Bearer {os.environ['HEARTBIT_API_KEY']}"},
    json={"to": "+15551234567"},
)

heartbit answers 202 Accepted. The code itself is never included in the response.

{
  "id": 318,
  "to": "15551234567",
  "status": "pending",
  "expiresAt": "2026-09-10T14:08:22Z",
  "messageId": 1841
}

Check a code

POST/api/v1/verify/check

Send the phone number and the code the person entered. The answer is always 200 OK; read status to see the result.

curl -X POST https://app.heartbit.biz/api/v1/verify/check \
  -H "Authorization: Bearer $HEARTBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+15551234567", "code": "482913" }'
const response = await fetch("https://app.heartbit.biz/api/v1/verify/check", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.HEARTBIT_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ to: "+15551234567", code: "482913" }),
});
const { status } = await response.json();
if (status === "approved") {
  // The number is verified.
}
response = requests.post(
    "https://app.heartbit.biz/api/v1/verify/check",
    headers={"Authorization": f"Bearer {os.environ['HEARTBIT_API_KEY']}"},
    json={"to": "+15551234567", "code": "482913"},
)
if response.json()["status"] == "approved":
    pass  # The number is verified.
statusMeaning
approvedThe code is correct. This is the only result that means the number is verified.
incorrectThe code is wrong. The person can try again until they run out of attempts.
expiredThe code is more than 5 minutes old, or a newer code was sent.
too_many_attemptsThere were 5 wrong attempts, so the code is locked. Send a new one.
not_foundThere’s no active code for that number, or it was already used.

Look up codes

GET/api/v1/verify/{id}
GET/api/v1/verify

Get one verification by id, or a list of recent ones, newest first. Each record shows the number, its status (Pending, Verified, Expired or Failed), attempts, and whether the text carrying the code was delivered. The list accepts to, status, since, until, page and pageSize (up to 200).

Set up webhooks

Webhooks tell your server when something happens, so you don’t have to keep asking. heartbit sends a POST request with a JSON body to your URL.

  1. In your dashboard, open API and add an endpoint with your URL. It must be a public https address.
  2. Choose the events you want to receive.
  3. Copy the signing secret. It’s shown only once and is used to verify each delivery.
  4. Send a test event from the API page to check everything works.

Events and payloads

EventWhen it’s sent
message.receivedA text message arrived on one of your gateways.
message.deliveredA message you sent was accepted by the carrier.
message.failedA message you sent couldn’t be delivered, or waited too long for a phone.

Every delivery has the same shape: the event type, when it happened, and the details in data.

{
  "type": "message.received",
  "createdAt": "2026-09-10T14:12:05Z",
  "data": {
    "id": 1852,
    "contact": "+15551234567",
    "text": "C",
    "gatewayId": "6f1c2b7e-3d4a-4f7e-9b1a-2c8d5e6f7a90",
    "gatewayNumber": "+15550134",
    "simSlot": 0,
    "receivedAt": "2026-09-10T14:12:05Z"
  }
}

Each request also includes these headers:

HeaderDescription
X-Heartbit-SignatureThe signature to verify, such as t=1757513525,v1=5f2b….
X-Heartbit-EventThe event type, such as message.received.
X-Heartbit-DeliveryA unique id for this delivery. Use it to ignore repeats.

Verify signatures

Always check the signature before trusting a delivery, so you know it came from heartbit:

  1. Read t (a timestamp) and v1 (the signature) from the X-Heartbit-Signature header.
  2. Join the timestamp, a full stop and the raw request body: t + "." + body.
  3. Compute an HMAC-SHA256 of that string using your signing secret.
  4. Compare it with v1 using a constant-time comparison, and reject timestamps older than 5 minutes.

Use the raw body exactly as it arrived. If your framework parses the JSON first and you re-encode it, the signature won’t match.

const crypto = require("crypto");

function isFromHeartbit(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (!parts.t || !parts.v1) return false;

  // Reject old deliveries so a captured request can't be replayed.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
import time

def is_from_heartbit(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(item.split("=", 1) for item in header.split(","))
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return False

    # Reject old deliveries so a captured request can't be replayed.
    if abs(time.time() - int(timestamp)) > 300:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Retries

Respond with any 2xx status within 10 seconds to confirm you received a delivery. Do any slow work after responding.

If your server doesn’t respond in time or returns an error, heartbit tries again after 10 seconds, 30 seconds, 2 minutes, 5 minutes and 15 minutes. Because a delivery can occasionally arrive more than once, use the X-Heartbit-Delivery id to skip ones you’ve already handled.

After 20 failed deliveries in a row, the endpoint is paused and the reason is shown on the API page. Turn it back on once your server is fixed.

Message statuses

StatusMeaning
PendingQueued and waiting for the gateway phone to pick it up.
ProcessingThe phone has picked it up and is sending it.
SentThe carrier accepted the message.
FailedThe message couldn’t be sent, for example because the SIM has no credit or signal, or the phone stayed offline too long.

Errors

Errors use standard HTTP status codes and a JSON body with a stable code you can check in your software:

{
  "error": {
    "code": "gateway_required",
    "message": "This account has more than one gateway. Set 'gatewayId' to choose one."
  }
}
CodeStatusWhat to do
invalid_request400A required field is missing, or the text is over 1,600 characters.
gateway_required400Your account has more than one gateway. Set gatewayId.
no_gateway400Connect a gateway phone to your account first.
sim_unavailable400That SIM is turned off on the phone. Choose another simSlot.
invalid_number400The number can’t receive a verification code. Check it’s a full phone number.
unauthorized401The API key is missing, mistyped or revoked.
402Your account is paused. Restore it in Settings → Billing.
insufficient_scope403The key doesn’t have the permission this request needs.
account_inactive403The account is not active. Contact support.
country_not_allowed403Your account doesn’t send codes to that country. Add it in Settings.
gateway_not_found404No gateway with that id on your account.
not_found404No message or verification with that id on your account.
too_soon429A code was sent to that number less than a minute ago.
number_limited429That number has had its limit of codes for the hour or day.
rate_limited429Too many requests. Wait the number of seconds in the Retry-After header.

Limits

LimitValue
Requests per API key120 per minute
Message length1,600 characters
Messages per page200
Active API keys per account20
Webhook endpoints per account10
Free API requests50,000 per month, then $0.20 per 1,000

Need help with your integration? Contact support.