Workflow webhooks

Let your Workflow journeys talk to your own systems: look up live data part-way through a journey, and receive SMS replies and delivery receipts as they happen.

A Remote Query action in a Workflow journey calls your server. We POST JSON to your URL with HTTP Basic authentication, and your server replies with JSON containing "status": "SUCCESS" or "FAILURE", plus any values the journey needs.

Use it to:

  • Look up data: fetch a customer’s current balance before sending a payment reminder, or check whether they’ve already paid before making a follow-up call.
  • Receive SMS replies: get each reply to a journey’s SMS in your own system (sms.inbox).
  • Receive delivery receipts: know when each journey SMS was delivered or failed (sms.delivery).

This is separate from submitting records to Workflow, where you call us.

Setting up

Each Remote Query is an action in the journey, set up in the Workflow designer:

Remote Query action settings in the everymessage Workflow designer
A Remote Query action that looks up a customer’s balance and copies the answer into journey variables.
SettingWhat it does
ConnectionYour server: its URL plus the key and secret we send with every call. Set up once, used by any number of actions.
Query nameAdded to the end of the connection URL, so your server knows which query is being asked, for example custom.query.
Request propertyEach line adds a field to the JSON we send. Its value comes from the record (such as primary_reference) or is a fixed value (such as BALANCE).
Response propertyEach line copies a field from your reply into a journey variable, so later actions can use it, for example in the text of an SMS.
On success / failure / errorThe next action in the journey when your server replies SUCCESS, replies FAILURE, or can’t be reached.

Requests

POSThttps://your-server.example.com/remote.query/<query name>
  • The body is JSON and always includes a request_id. Return the same request_id in your reply.
  • We authenticate with HTTP Basic, using the key and secret in the connection settings. Reject any request where they don’t match.
  • Reply with HTTP 200 and a JSON body, quickly: the journey waits for your answer.
  • Use HTTPS, and return 404 for any query name you don’t recognise.

Custom lookups

The fields sent are the request properties you set up in the action. With the settings shown above, a lookup looks like this:

Request

{
  "request_id": "8f2c41d7",
  "query_type": "BALANCE",
  "reference": "CUST-1042"
}

Reply: success

{
  "request_id": "8f2c41d7",
  "status": "SUCCESS",
  "balance": 240.78,
  "arrears": 36.0,
  "nextpaymentdate": "2026-11-28"
}

Reply: failure

{
  "request_id": "8f2c41d7",
  "status": "FAILURE",
  "statusMessage": "Account not found"
}

The journey copies balance, nextpaymentdate and arrears into its variables (my_balance, next_payment_date and arrears), then moves on to the success action, here an SMS quoting the balance.

Inbound SMS

Query name sms.inbox: a reply to one of the journey’s SMS.

{
  "request_id": "8f2c41d8",
  "messageid": 31104,
  "from": "07700900123",
  "to": "15551",
  "body": "Yes, please call me",
  "date": "2026-10-11T10:25:43.511Z",
  "reference": "CUST-1042"
}
FieldTypeDescription
messageidintegerOur id for the incoming message.
fromstringThe mobile number that sent it.
tostringThe number or short code it was sent to.
bodystringThe message text.
datestringWhen it was received, ISO 8601 in UTC.
referencestringThe reference of the record it relates to.

Delivery receipts

Query name sms.delivery: the delivery result for an SMS sent by the journey.

{
  "request_id": "8f2c41d9",
  "messageid": 160714,
  "date": "2026-10-11T10:25:43.511Z",
  "status": 2,
  "code": 4
}
FieldTypeDescription
messageidintegerOur id for the SMS that was sent.
datestringWhen the network reported the result, ISO 8601 in UTC.
statusinteger2 delivered, 1 pending, -1 failed.
codeintegerThe detailed result code. See below.
Delivery codes
CodeMeaning
0Submitted, no errors
4Delivered, no errors
5Failed, unknown reason
6Submitted, final status unknown
8Expired by the network
20Permanent failure: number not valid
21Premium SMS credit issue
23Absent subscriber, permanent: number not known
24Absent subscriber, temporary: message expired
25Operator failure
26Phone error, for example SIM or memory full, or busy
27Permanent phone error
28Rejected as spam by the operator
29Content not permitted
1025Number blacklisted
1026Client blacklisted
1027Prefix blacklisted
1028Account error
1030Destination busy
1032Syntax error
1053Number unroutable
1061Number blocked from using SMS
1063Sequence error
1068Message routing error

Your reply

FieldTypeDescription
request_id requiredstringThe request_id from the request.
status requiredstringSUCCESS or FAILURE. Decides which action the journey runs next.
statusMessagestringRequired when status is FAILURE: the reason, for your reports.
any other fieldanyFor custom lookups, the values to copy into journey variables, named as in the action’s response properties.

Code examples

Each example handles all three query types at /remote.query/<query name>, so set your connection URL to https://your-server.example.com/remote.query. They check the key and secret from the connection settings: RemoteQuery:Key and RemoteQuery:Secret in C#, and EM_RQ_KEY and EM_RQ_SECRET in Python and Node.js. The C# example is an ASP.NET Core minimal API (dotnet new web); Python needs pip install fastapi uvicorn; Node.js needs version 18 or later and no packages.

C#

// Answer Workflow Remote Query calls: custom lookups, inbound SMS and delivery receipts
// (ASP.NET Core minimal API, .NET 8 or later)
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;

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

// The key and secret entered in the Workflow connection settings.
var expected = Encoding.UTF8.GetBytes(
    $"{app.Configuration["RemoteQuery:Key"]}:{app.Configuration["RemoteQuery:Secret"]}");

bool Authorized(HttpRequest req)
{
    var header = req.Headers.Authorization.ToString();
    if (!header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) return false;
    try { return CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(header[6..]), expected); }
    catch (FormatException) { return false; }
}

app.MapPost("/remote.query/{method}", (string method, JsonObject body, HttpRequest req,
    ILogger<Program> log) =>
{
    if (!Authorized(req)) return Results.Unauthorized();

    // Always echo request_id so Workflow can match the reply to its request.
    var reply = new JsonObject { ["request_id"] = body["request_id"]?.DeepClone() };

    switch (method.ToLowerInvariant())
    {
        case "custom.query": // the "Query name" set in the Remote Query action
            if ((string?)body["query_type"] == "BALANCE")
            {
                var reference = (string?)body["reference"];
                // Look the customer up here. Each value below maps to a journey variable.
                reply["status"] = "SUCCESS";
                reply["balance"] = 240.78;
                reply["arrears"] = 36.00;
                reply["nextpaymentdate"] = "2026-11-28";
            }
            else
            {
                reply["status"] = "FAILURE";
                reply["statusMessage"] = "Unknown query type";
            }
            break;

        case "sms.inbox":
            log.LogInformation("SMS {Id} from {From} to {To}: {Body}",
                body["messageid"], body["from"], body["to"], body["body"]);
            reply["status"] = "SUCCESS";
            break;

        case "sms.delivery":
            log.LogInformation("Message {Id}: status {Status}, code {Code}",
                body["messageid"], body["status"], body["code"]);
            reply["status"] = "SUCCESS";
            break;

        default:
            return Results.NotFound();
    }

    return Results.Ok(reply);
});

app.Run();

Python

"""Answer Workflow Remote Query calls: custom lookups, inbound SMS and delivery receipts.

pip install fastapi uvicorn
Run with:  uvicorn remote_query:app --host 0.0.0.0 --port 8000
"""
import os
import secrets

from fastapi import Body, Depends, FastAPI, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()
basic = HTTPBasic()

# The key and secret entered in the Workflow connection settings.
KEY = os.environ["EM_RQ_KEY"].encode()
SECRET = os.environ["EM_RQ_SECRET"].encode()


def check(creds: HTTPBasicCredentials = Depends(basic)) -> None:
    ok_key = secrets.compare_digest(creds.username.encode(), KEY)
    ok_secret = secrets.compare_digest(creds.password.encode(), SECRET)
    if not (ok_key and ok_secret):
        raise HTTPException(status_code=401, headers={"WWW-Authenticate": "Basic"})


@app.post("/remote.query/{method}", dependencies=[Depends(check)])
def remote_query(method: str, body: dict = Body(...)):
    # Always echo request_id so Workflow can match the reply to its request.
    reply = {"request_id": body.get("request_id")}

    match method.lower():
        case "custom.query":  # the "Query name" set in the Remote Query action
            if body.get("query_type") == "BALANCE":
                reference = body.get("reference")
                # Look the customer up here. Each value below maps to a journey variable.
                reply |= {"status": "SUCCESS", "balance": 240.78, "arrears": 36.00,
                          "nextpaymentdate": "2026-11-28"}
            else:
                reply |= {"status": "FAILURE", "statusMessage": "Unknown query type"}
        case "sms.inbox":
            print(f"SMS {body.get('messageid')} from {body.get('from')} "
                  f"to {body.get('to')}: {body.get('body')}")
            reply["status"] = "SUCCESS"
        case "sms.delivery":
            print(f"Message {body.get('messageid')}: status {body.get('status')}, "
                  f"code {body.get('code')}")
            reply["status"] = "SUCCESS"
        case _:
            raise HTTPException(status_code=404)

    return reply

Node.js

// Answer Workflow Remote Query calls: custom lookups, inbound SMS and delivery receipts
// (Node.js 18 or later, no packages needed). Run with:  node remote_query.js
const http = require("node:http");
const crypto = require("node:crypto");

// The key and secret entered in the Workflow connection settings.
const expected = Buffer.from(`${process.env.EM_RQ_KEY}:${process.env.EM_RQ_SECRET}`);

function authorized(req) {
  const [scheme, token] = (req.headers.authorization || "").split(" ");
  if (scheme !== "Basic" || !token) return false;
  const supplied = Buffer.from(token, "base64");
  return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
}

http.createServer(async (req, res) => {
  const match = req.url.match(/^\/remote\.query\/([^/?]+)/);
  if (req.method !== "POST" || !match) return res.writeHead(404).end();
  if (!authorized(req)) return res.writeHead(401).end();

  let body;
  try {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    return res.writeHead(400).end();
  }

  // Always echo request_id so Workflow can match the reply to its request.
  const reply = { request_id: body.request_id };

  switch (match[1].toLowerCase()) {
    case "custom.query": // the "Query name" set in the Remote Query action
      if (body.query_type === "BALANCE") {
        // Look the customer up here (body.reference). Each value maps to a journey variable.
        Object.assign(reply, { status: "SUCCESS", balance: 240.78, arrears: 36.0, nextpaymentdate: "2026-11-28" });
      } else {
        Object.assign(reply, { status: "FAILURE", statusMessage: "Unknown query type" });
      }
      break;
    case "sms.inbox":
      console.log(`SMS ${body.messageid} from ${body.from} to ${body.to}: ${body.body}`);
      reply.status = "SUCCESS";
      break;
    case "sms.delivery":
      console.log(`Message ${body.messageid}: status ${body.status}, code ${body.code}`);
      reply.status = "SUCCESS";
      break;
    default:
      return res.writeHead(404).end();
  }

  res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(reply));
}).listen(process.env.PORT || 8881);