Receive SMS

Receive the replies people send to your numbers.

Choose webhook to have each reply posted to your server within seconds, or collect to fetch new replies with a GET request when it suits you. Replies to an alphanumeric sender such as everymessage are not possible over SMS, so send from a number if you want answers.

Webhook

We POST new messages to your URL as JSON. Tell us the URL and we will connect it to your numbers.

POSThttps://your-server.example.com/…
{
  "list": [
    {
      "type": "INBOUND_MESSAGE",
      "messageId": 90321,
      "sender": "+447700900123",
      "inboxName": "support",
      "inboxNumber": "447700900000",
      "content": "Yes, 10:30 is fine",
      "dateTime": "2026-10-11T09:31:12.408Z"
    }
  ]
}
FieldTypeDescription
listarrayOne or more messages. Usually one, but handle several.
typestringINBOUND_MESSAGE.
messageIdintegerOur id for the message. Use it to ignore a repeat delivery.
senderstringThe mobile number that sent it.
inboxNamestringThe inbox the number belongs to, if any.
inboxNumberstringYour number that received it.
inboxKeywordstringThe keyword matched, if your number uses keywords. Left out otherwise.
contentstringThe message text.
dateTimestringWhen it was received, ISO 8601 in UTC.

Your response

Reply with any 2xx status within 10 seconds. If we get an error or no answer, we retry the batch several times over the next few minutes, so make your handler safe to run twice for the same messageId. Older integrations also return {"echo":"ACK"}; that is accepted but not required.

Securing your webhook

  • Verify the signature. Check X-EM-Signature-V2 on every request: see Webhook signatures.
  • Use HTTPS.
  • If you can’t verify signatures, put a long random token in the URL, for example https://example.com/everymessage/inbound/3f9c…, and reject any request without it. The webhook receiver examples below do this.
  • Optionally allow only 13.134.161.74, the fixed address our webhooks come from.

Webhook signatures

When a webhook has a signing secret, every inbound message and delivery receipt we POST to it is signed, so you can confirm it came from everymessage and wasn’t altered or replayed. New webhooks get a secret automatically. It’s shown once in the portal under Admin → Webhooks, where you can also generate a new one.

HeaderValue
X-EM-TimestampUnix time in seconds when the request was sent.
X-EM-Signature-V2v2= followed by the lowercase hex HMAC-SHA256 of {X-EM-Timestamp}.{raw body}, keyed with your secret.
X-EM-SignatureLowercase hex HMAC-SHA256 of the raw body alone. Kept for existing integrations; it doesn’t protect against replays.

To verify (use V2)

  1. Read the raw body. Take the request body as bytes, before any JSON parsing. Re-serialising the JSON would change the bytes and the signature.
  2. Compute the signature. Join X-EM-Timestamp, a full stop and the body, compute HMAC-SHA256 with your secret, and hex-encode it in lowercase.
  3. Compare. Compare it with the value after v2= using a constant-time comparison.
  4. Check the time. Reject the request if X-EM-Timestamp is more than 5 minutes away from your clock.

Each retry is signed again with a new timestamp. Reply with any 2xx status to acknowledge. Tested verifiers in C#, Python and Node.js are under Code examples.

Notes

  • If a phone number or WhatsApp account has its own secret, that secret signs its inbound messages instead of the webhook’s.
  • PBX-format webhooks (3CX and Yeastar) are signed with X-Signature-256: sha256=<hex> instead. See Phone systems (PBX).
  • A callback URL entered directly on a user, number or brand, rather than choosing a webhook, is signed only if that number or account has its own secret.

Collect by API

GEThttps://api.everymessage.com/v1/inbound_messages

Returns messages you have not collected yet, and marks them collected. Each message is returned once, so store it before your next call. When there is nothing new, responseStatusReason is NO_CURRENT_ENTRIES. Messages wait until you collect them; they are not deleted if you poll less often.

ParameterTypeDescription
inboxstringLimit to one or more inboxes, by id or name. Repeat it, or separate with commas. Omit, or use *, for all.
limitintegerMaximum messages per call. Default 500, maximum 1000. Call again until you get NO_CURRENT_ENTRIES to clear a backlog.

Parameters can also be sent as a JSON body with POST /v1/inbound_messages.

{
  "apiName": "everymessage Web API",
  "apiVersion": "1.0.2",
  "apiTime": "11 October 2026 10:32:00",
  "apiState": "RUNNING",
  "responseStatusCode": 200,
  "processingTime": 12,
  "body": [
    {
      "type": "INBOUND_MESSAGE",
      "messageId": 90321,
      "sender": "447700900123",
      "inboxName": "support",
      "inboxNumber": "447700900000",
      "content": "Yes, 10:30 is fine",
      "dateTime": "2026-10-11 10:31:12"
    }
  ]
}

The fields match the webhook, except that dateTime is UK local time in the form yyyy-MM-dd HH:mm:ss and sender has no +. Use GET /v1/get_inboxes to list your inboxes.

Code examples

Webhook receiver

Set EmWebhookToken (C#) or EM_WEBHOOK_TOKEN (Python) to a long random value and give us the URL including it. The same app also receives delivery receipts. The C# example is an ASP.NET Core minimal API (dotnet new web); the Python example needs pip install fastapi uvicorn.

C#

// Receive inbound SMS and delivery receipts by webhook (ASP.NET Core minimal API, .NET 8 or later)
using System.Security.Cryptography;
using System.Text;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// A long random token in the URL stops anyone else posting to your endpoints.
var token = Encoding.UTF8.GetBytes(app.Configuration["EmWebhookToken"]
            ?? throw new InvalidOperationException("Set EmWebhookToken"));
bool Valid(string key) => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(key), token);

app.MapPost("/everymessage/inbound/{key}", (string key, CallbackBatch<InboundMessage> batch,
    ILogger<Program> log) =>
{
    if (!Valid(key)) return Results.NotFound();

    foreach (var m in batch.List)
        log.LogInformation("SMS {Id} from {Sender} to {Inbox}: {Content}",
            m.MessageId, m.Sender, m.InboxNumber, m.Content);

    // Any 2xx response tells everymessage the batch was received.
    return Results.Ok(new { echo = "ACK" });
});

app.MapPost("/everymessage/receipts/{key}", (string key, CallbackBatch<DeliveryReceipt> batch,
    ILogger<Program> log) =>
{
    if (!Valid(key)) return Results.NotFound();

    foreach (var r in batch.List)
        log.LogInformation("Message {Id} ({Reference}) to {Recipient}: {Status}",
            r.MessageId, r.Reference, r.Recipient, r.Status);

    return Results.Ok(new { echo = "ACK" });
});

app.Run();

record CallbackBatch<T>(IReadOnlyList<T> List);
record InboundMessage(string Type, long MessageId, string Sender, string? InboxName,
    string? InboxNumber, string? InboxKeyword, string Content, DateTimeOffset DateTime);
record DeliveryReceipt(string Type, long MessageId, string? Reference, string Recipient,
    string Status, DateTimeOffset DateTime);

Python

"""Receive inbound SMS and delivery receipts by webhook (pip install fastapi uvicorn).

Run with:  uvicorn webhooks:app --host 0.0.0.0 --port 8000
"""
import os
import secrets
from datetime import datetime

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI()

# A long random token in the URL stops anyone else posting to your endpoints.
TOKEN = os.environ["EM_WEBHOOK_TOKEN"]


class InboundMessage(BaseModel):
    type: str
    messageId: int
    sender: str
    inboxName: str | None = None
    inboxNumber: str | None = None
    inboxKeyword: str | None = None
    content: str
    dateTime: datetime


class DeliveryReceipt(BaseModel):
    type: str
    messageId: int
    reference: str | None = None
    recipient: str
    status: str
    dateTime: datetime


class InboundBatch(BaseModel):
    items: list[InboundMessage] = Field(alias="list")


class ReceiptBatch(BaseModel):
    items: list[DeliveryReceipt] = Field(alias="list")


def check(key: str) -> None:
    if not secrets.compare_digest(key, TOKEN):
        raise HTTPException(status_code=404)


@app.post("/everymessage/inbound/{key}")
def inbound(key: str, batch: InboundBatch):
    check(key)
    for m in batch.items:
        print(f"SMS {m.messageId} from {m.sender} to {m.inboxNumber}: {m.content}")
    # Any 2xx response tells everymessage the batch was received.
    return {"echo": "ACK"}


@app.post("/everymessage/receipts/{key}")
def receipts(key: str, batch: ReceiptBatch):
    check(key)
    for r in batch.items:
        print(f"Message {r.messageId} ({r.reference}) to {r.recipient}: {r.status}")
    return {"echo": "ACK"}

Verify webhook signatures

Set EM_WEBHOOK_SECRET to your webhook’s signing secret. Each example checks X-EM-Signature-V2 and the timestamp on the raw body before parsing the JSON, and returns 401 if either fails. C#: ASP.NET Core minimal API (dotnet new web). Python: pip install fastapi uvicorn. Node.js: npm install express.

C#

// Verify everymessage webhook signatures (X-EM-Signature-V2) in an ASP.NET Core minimal API (.NET 8 or later)
using System.Security.Cryptography;
using System.Text;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// The signing secret shown once when the webhook was created (Admin > Webhooks).
var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("EM_WEBHOOK_SECRET")!);

app.MapPost("/everymessage/webhook", async (HttpRequest request) =>
{
    // Verify the exact bytes received: parsing and re-serialising the JSON would change them.
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var body = buffer.ToArray();

    if (!Verify(body, request.Headers["X-EM-Timestamp"], request.Headers["X-EM-Signature-V2"], secret))
        return Results.Unauthorized();

    // Signature checked: now parse and process the JSON.
    Console.WriteLine($"Verified webhook: {Encoding.UTF8.GetString(body)}");
    return Results.Ok();
});

app.Run();

static bool Verify(byte[] body, string? timestamp, string? signatureHeader, byte[] secret)
{
    // Reject anything sent more than 5 minutes ago (or in the future) to stop replays.
    if (!long.TryParse(timestamp, out var ts) ||
        Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
        return false;

    // HMAC-SHA256 of "{timestamp}.{body}", lowercase hex.
    var signed = Encoding.ASCII.GetBytes(timestamp + ".").Concat(body).ToArray();
    var expected = Convert.ToHexString(HMACSHA256.HashData(secret, signed)).ToLowerInvariant();

    var received = signatureHeader is { } h && h.StartsWith("v2=") ? h[3..] : "";
    return CryptographicOperations.FixedTimeEquals(
        Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(received));
}

Python

"""Verify everymessage webhook signatures (X-EM-Signature-V2) with FastAPI.

pip install fastapi uvicorn, then run:  uvicorn verify_signature:app --host 0.0.0.0 --port 8000
"""
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

# The signing secret shown once when the webhook was created (Admin > Webhooks).
SECRET = os.environ["EM_WEBHOOK_SECRET"].encode()


def verify(raw_body: bytes, timestamp: str, signature_header: str) -> bool:
    # Reject anything sent more than 5 minutes ago (or in the future) to stop replays.
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    # HMAC-SHA256 of "{timestamp}.{body}", lowercase hex.
    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header.removeprefix("v2="), expected)


@app.post("/everymessage/webhook")
async def webhook(request: Request):
    # Verify the exact bytes received: parsing and re-serialising the JSON would change them.
    raw_body = await request.body()
    if not verify(raw_body,
                  request.headers.get("X-EM-Timestamp", ""),
                  request.headers.get("X-EM-Signature-V2", "")):
        raise HTTPException(status_code=401)

    # Signature checked: now parse and process the JSON.
    print("Verified webhook:", json.loads(raw_body))
    return {"ok": True}

Node.js

// Verify everymessage webhook signatures (X-EM-Signature-V2) with Express.
// npm install express, then run:  node verify_signature.js
const crypto = require('crypto');
const express = require('express');

// The signing secret shown once when the webhook was created (Admin > Webhooks).
const SECRET = process.env.EM_WEBHOOK_SECRET;

function verify(rawBody, timestamp, signatureHeader) {
  // Reject anything sent more than 5 minutes ago (or in the future) to stop replays.
  if (!/^d+$/.test(timestamp || '') || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  // HMAC-SHA256 of "{timestamp}.{body}", lowercase hex.
  const expected = crypto.createHmac('sha256', SECRET).update(timestamp + '.').update(rawBody).digest('hex');
  const received = (signatureHeader || '').replace(/^v2=/, '');
  return received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

const app = express();

// express.raw keeps the exact bytes received: parsing and re-serialising the JSON would change them.
app.post('/everymessage/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('X-EM-Timestamp'), req.get('X-EM-Signature-V2'))) {
    return res.sendStatus(401);
  }
  // Signature checked: now parse and process the JSON.
  console.log('Verified webhook:', JSON.parse(req.body));
  res.sendStatus(200);
});

app.listen(8000);

Collect by API

Polls once a minute and prints each new message.

C#

// Collect inbound SMS replies with the everymessage v1 API (.NET 8 or later)
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;

var apiKey = Environment.GetEnvironmentVariable("EM_API_KEY")!;
var secretKey = Environment.GetEnvironmentVariable("EM_SECRET_KEY")!;

using var http = new HttpClient { BaseAddress = new Uri("https://api.everymessage.com/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{apiKey}:{secretKey}")));

using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
do
{
    // Each message is returned once: store it before the next call.
    var result = await http.GetFromJsonAsync<ApiEnvelope<List<InboundMessage>>>(
        "v1/inbound_messages?limit=500")
        ?? throw new InvalidOperationException("Empty response from everymessage");

    switch (result.ResponseStatusReason)
    {
        case null:
            foreach (var m in result.Body ?? [])
                Console.WriteLine($"{m.DateTime} from {m.Sender} to {m.InboxNumber}: {m.Content}");
            break;
        case "NO_CURRENT_ENTRIES":
            break; // nothing new
        default:
            throw new InvalidOperationException($"Collect failed: {result.ResponseStatusReason}");
    }
}
while (await timer.WaitForNextTickAsync());

record ApiEnvelope<T>(int ResponseStatusCode, string? ResponseStatusReason, int ProcessingTime, T? Body);
record InboundMessage(string Type, long MessageId, string Sender, string? InboxName,
    string? InboxNumber, string? InboxKeyword, string Content, string DateTime);

Python

"""Collect inbound SMS replies with the everymessage v1 API (Python 3.10+, pip install httpx)."""
import os
import time

import httpx

with httpx.Client(
    base_url="https://api.everymessage.com",
    auth=(os.environ["EM_API_KEY"], os.environ["EM_SECRET_KEY"]),
    timeout=30,
) as client:
    while True:
        # Each message is returned once: store it before the next call.
        response = client.get("/v1/inbound_messages", params={"limit": 500})
        response.raise_for_status()
        result = response.json()

        reason = result.get("responseStatusReason")
        if reason is None:
            for m in result["body"]:
                print(f"{m['dateTime']} from {m['sender']} to {m['inboxNumber']}: {m['content']}")
        elif reason != "NO_CURRENT_ENTRIES":
            raise RuntimeError(f"Collect failed: {reason}")

        time.sleep(60)