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
- Sign in to your dashboard as an admin and open API.
- 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.
- 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
Queues a text message on one of your gateways. Needs the Send messages permission.
| Field | Description |
|---|---|
| torequired | The recipient’s phone number, preferably in international format, such as +15551234567. |
| textrequired | The message, up to 1,600 characters. Longer messages are sent in parts automatically. |
| gatewayId | Which gateway to send from. Required if your account has more than one gateway. Find ids with List gateways. |
| simSlot | Which SIM to use on a dual-SIM phone: 0 for the first (default) or 1 for the second. |
| idempotencyKey | A 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
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
Returns messages on your account, newest first. Needs the Read messages permission.
| Query parameter | Description |
|---|---|
| contact | Only messages to or from this phone number. |
| direction | inbound for received messages, or outbound for sent ones. |
| limit | How many messages to return, from 1 to 200. Default 50. |
| before | Return 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
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
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
| Field | Description |
|---|---|
| torequired | The phone number to verify. |
| gatewayId | Which gateway to send from, if your account has more than one. |
| simSlot | Which 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
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.| status | Meaning |
|---|---|
| approved | The code is correct. This is the only result that means the number is verified. |
| incorrect | The code is wrong. The person can try again until they run out of attempts. |
| expired | The code is more than 5 minutes old, or a newer code was sent. |
| too_many_attempts | There were 5 wrong attempts, so the code is locked. Send a new one. |
| not_found | There’s no active code for that number, or it was already used. |
Look up codes
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.
- In your dashboard, open API and add an endpoint with your URL. It must be a public
httpsaddress. - Choose the events you want to receive.
- Copy the signing secret. It’s shown only once and is used to verify each delivery.
- Send a test event from the API page to check everything works.
Events and payloads
| Event | When it’s sent |
|---|---|
| message.received | A text message arrived on one of your gateways. |
| message.delivered | A message you sent was accepted by the carrier. |
| message.failed | A 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:
| Header | Description |
|---|---|
| X-Heartbit-Signature | The signature to verify, such as t=1757513525,v1=5f2b…. |
| X-Heartbit-Event | The event type, such as message.received. |
| X-Heartbit-Delivery | A 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:
- Read
t(a timestamp) andv1(the signature) from theX-Heartbit-Signatureheader. - Join the timestamp, a full stop and the raw request body:
t + "." + body. - Compute an HMAC-SHA256 of that string using your signing secret.
- Compare it with
v1using 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
| Status | Meaning |
|---|---|
| Pending | Queued and waiting for the gateway phone to pick it up. |
| Processing | The phone has picked it up and is sending it. |
| Sent | The carrier accepted the message. |
| Failed | The 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."
}
}| Code | Status | What to do |
|---|---|---|
| invalid_request | 400 | A required field is missing, or the text is over 1,600 characters. |
| gateway_required | 400 | Your account has more than one gateway. Set gatewayId. |
| no_gateway | 400 | Connect a gateway phone to your account first. |
| sim_unavailable | 400 | That SIM is turned off on the phone. Choose another simSlot. |
| invalid_number | 400 | The number can’t receive a verification code. Check it’s a full phone number. |
| unauthorized | 401 | The API key is missing, mistyped or revoked. |
| — | 402 | Your account is paused. Restore it in Settings → Billing. |
| insufficient_scope | 403 | The key doesn’t have the permission this request needs. |
| account_inactive | 403 | The account is not active. Contact support. |
| country_not_allowed | 403 | Your account doesn’t send codes to that country. Add it in Settings. |
| gateway_not_found | 404 | No gateway with that id on your account. |
| not_found | 404 | No message or verification with that id on your account. |
| too_soon | 429 | A code was sent to that number less than a minute ago. |
| number_limited | 429 | That number has had its limit of codes for the hour or day. |
| rate_limited | 429 | Too many requests. Wait the number of seconds in the Retry-After header. |
Limits
| Limit | Value |
|---|---|
| Requests per API key | 120 per minute |
| Message length | 1,600 characters |
| Messages per page | 200 |
| Active API keys per account | 20 |
| Webhook endpoints per account | 10 |
| Free API requests | 50,000 per month, then $0.20 per 1,000 |
Need help with your integration? Contact support.