</>CodeWithKarani

Bolting a phone onto ERPNext: custom routes, guest endpoints and HMAC auth in Frappe

Karani GeoffreyKarani Geoffrey12 min read

The SMS gateway on the till phone posts to three fixed paths. That is a deliberate constraint: the operator configures a base URL and nothing else, because the fewer things there are to type into a phone in a shop, the fewer support calls there are later.

POST  /api/sms/incoming
POST  /api/sms/heartbeat
GET   /api/app/version

Point that at a FastAPI service and you write three route decorators and go home. Point it at ERPNext and the first request 404s, because Frappe has opinions about what lives under /api/, and none of them are yours.

This article is how the real integration works, in a shipping UpeoRetail installation. The routing trick is worth knowing on its own, and the auth model is worth arguing about.

rewrite the path in a before_request hook, bust the cached request.path as well as the WSGI environment, and return raw werkzeug responses so the client sees your status codes rather than Frappe's envelope.

  • Frappe routes all of /api/* to its own handler, which only understands method, resource, v1 and v2.
  • request.path is a cached property already read by init_request, so setting PATH_INFO alone is not enough.
  • allow_guest=True is fine here, because the endpoints are authenticated by HMAC, a fresh timestamp and an unused nonce, not by a session.
  • Store the device secret in a Password field and read it with get_decrypted_password.

Why the path 404s

Frappe's request handling checks for the /api/ prefix early and dispatches to its own API layer before website routing is consulted. That layer recognises a small, fixed set of shapes: /api/method/<dotted.path>, /api/resource/<DocType>, and the versioned /api/v1 and /api/v2 namespaces. Anything else under /api/ is not a website route that Frappe has not found yet. It is a malformed API call, and it is rejected as one.

Which means there is no place to put a www page, no website_route_rules entry, and no clever @frappe.whitelist decorator that makes /api/sms/incoming reachable. The path never gets far enough for any of those to be consulted.

How a fixed third-party path is rewritten onto a whitelisted Frappe method A request for slash api slash sms slash incoming arrives. Without the hook it reaches Frappe's API handler, which only recognises method, resource, v1 and v2, and returns 404. With the before_request hook, the path is rewritten to slash api slash method slash the dotted method name, in both the WSGI environment and the cached request path property, so the URL map matches and the whitelisted function runs. POST /api/sms/incoming init_request, then before_request hooks no hook Frappe API handler only method, resource, v1, v2 are understood 404 route_sms_gateway rewrite PATH_INFO and the cached .path /api/method/upeoretail. api.sms_gateway.incoming 200 accepted Same request. The only difference is one hook.
The rewrite happens before the URL map is consulted, which is the only window where it is still possible.

The rewrite, and the cache you will miss

The hook is registered in hooks.py:

before_request = [
    "upeoretail.api.sms_gateway.route_sms_gateway",
]

And the function itself is short, which makes the one non-obvious line easy to spot:

GATEWAY_ROUTES = {
    "/api/sms/incoming": "upeoretail.api.sms_gateway.incoming",
    "/api/sms/heartbeat": "upeoretail.api.sms_gateway.heartbeat",
    "/api/app/version": "upeoretail.api.sms_gateway.app_version",
}


def route_sms_gateway():
    req = getattr(frappe.local, "request", None)
    if req is None:
        return
    path = (req.path or "").rstrip("/")
    target = GATEWAY_ROUTES.get(path)
    if not target:
        return
    new_path = "/api/method/" + target
    req.environ["PATH_INFO"] = new_path      # what the URL map matches
    req.__dict__["path"] = new_path          # bust the cached property
    req.__dict__.pop("full_path", None)

Setting PATH_INFO is the obvious half. It is the WSGI environment value that the URL map actually matches against, so without it nothing routes anywhere.

The second and third lines are the half that costs an afternoon. In werkzeug, request.path is a cached property: the first read computes it from the environment and stores the result on the instance. Frappe's init_request runs before before_request hooks and reads the path along the way, so by the time your hook runs the value is already memoised. Change PATH_INFO alone and you get a request whose environment says one thing and whose .path says another, and the resulting behaviour depends entirely on which of the two any given piece of downstream code happens to consult. Writing straight into req.__dict__ replaces the cached value. Popping full_path clears the sibling cache that would otherwise still carry the old value with its query string.

Reaching into an object's __dict__ is not something to do casually. It is justified here because the alternative is a request in two minds about its own URL, and because the three lines sit together with a comment explaining exactly what each one is for. If a future werkzeug stops caching, the lines become harmless no-ops rather than breakage.

Note also the allowlist shape. The hook does not pattern-match a prefix, it looks up an exact path in a three-entry dictionary and returns immediately for everything else. A before_request hook runs on every single request that hits the site, including every desk page load, so it has to be nearly free and it has to be impossible to accidentally capture a path that belongs to somebody else.

Escaping the JSON envelope

Frappe wraps whitelisted return values in its own structure and maps exceptions onto its own status codes. That is helpful when the caller is Frappe's own client, and it is a problem when the caller is a phone app implementing a contract that was written before ERPNext was chosen as the backend.

The contract says a duplicate returns 200 with a body of {"status": "duplicate"}. It does not say 200 with {"message": {"status": "duplicate"}}, and the phone will not unwrap it. So the endpoints construct their own responses:

def _json(payload, status=200):
    from werkzeug.wrappers import Response

    return Response(json.dumps(payload), status=status,
                    content_type="application/json")

Every return path in the module goes through that helper, which has a pleasant side effect: the endpoint reads as a straight sequence of guard clauses that each return a status code, rather than a mix of frappe.throw calls whose HTTP mapping you have to remember.

row, secret = _device_and_secret(payload.get("device_id"))
if not secret:
    return _json({"status": "error", "detail": "unknown device"}, 401)

# 1) integrity - recompute message_hash
if _message_hash(payload["sender"], payload["message"],
                 payload["received_at"]) != payload["message_hash"]:
    return _json({"status": "error", "detail": "message_hash mismatch"}, 400)

# 2) signature
if not hmac.compare_digest(_sign(_incoming_sts(payload), secret),
                           str(payload["signature"])):
    return _json({"status": "error", "detail": "invalid signature"}, 401)

Compare that with a frappe.throw(..., frappe.PermissionError), which gets you a 403, an HTML error page in some configurations, and a traceback in the error log for something that is a routine authentication failure rather than an exception.

Guest endpoints that are not unauthenticated

All three endpoints are declared like this:

@frappe.whitelist(allow_guest=True, methods=["POST"])
def incoming(**payload):
    ...

That reliably makes people uncomfortable, and the discomfort is worth unpacking, because it comes from conflating two different things.

allow_guest=True means Frappe will not require a session cookie or an API key before dispatching to the function. It does not mean the function is unauthenticated. It means Frappe is not the thing doing the authenticating.

What authenticates a request here is the HMAC contract: a signature over a byte-exact canonical string computed with a per-device secret, a sent_at inside a five-minute window, and a nonce that has never been used. A caller with none of those gets a 401 on the first check. A caller who somehow captured a valid request off the wire gets a 409 on the nonce.

Session auth would actually be worse here. A session means a credential that lives on the phone, gets refreshed, expires at inconvenient moments and can be replayed for its whole lifetime if stolen. The shared secret never crosses the wire at all: it is only ever used to compute a signature, so possession is proved without disclosure. The endpoint is guest-accessible and strongly authenticated at the same time, and those two statements are not in tension.

The one thing this does require is that the secret is stored properly. It is a Password fieldtype on the device DocType, which Frappe keeps out of the main table and encrypted at rest, and it is read through the dedicated helper:

def _device_and_secret(device_id):
    """Return (device_row, raw_secret) for an enabled device, else (None, None)."""
    if not device_id:
        return None, None
    row = frappe.db.get_value(DEVICE_DT, device_id, ["name", "enabled", "branch"],
                              as_dict=True)
    if not row or not cint(row.enabled):
        return None, None
    secret = get_decrypted_password(DEVICE_DT, device_id, "device_secret",
                                    raise_exception=False)
    if not secret:
        return None, None
    return row, secret

Three failure shapes collapse into one return value on purpose. Unknown device, disabled device and missing secret all produce (None, None) and the same 401. An attacker probing device ids learns nothing about which ones exist, and the operator disabling a stolen phone gets an instant, total cut-off with a single checkbox.

Three DocTypes

The data model is deliberately small.

The three gateway DocTypes and their relationship to the M-Pesa payment record A device DocType holds the device id, an enabled flag, a branch link, the encrypted device secret and a block of health fields updated by the heartbeat. A message DocType links to the device and holds the sender, raw body, received at, message hash, nonce, status and a link to an M-Pesa payment. A nonce DocType is the replay ledger. The M-Pesa payment record is shared with the Daraja webhook and carries a source field distinguishing the two. Gateway Device device_id, label, enabled branch, default_sim_slot device_secret (Password) last_heartbeat last_app_version last_connectivity, last_sms_at last_battery, last_charging pending, failed Gateway Message device_id (Link) sender, received_at, sim_slot message (Long Text) message_hash, nonce status: Received / Parsed / Ignored / Error mpesa_payment (Link) parse_error M-Pesa Payment transaction_code (unique) amount, phone_number payer_name, transaction_time source: SMS Gateway | Daraja gateway_message (Link) raw_payload, status shared with the Daraja webhook Gateway Nonce nonce, device_id, created_at the replay ledger, pruned daily The device row is also the fleet monitor: every heartbeat writes into the lower half of it, so a plain list view of devices becomes an operations dashboard.
Note what the message row does not have: any parsed M-Pesa field. Those live on the payment, which the Daraja webhook also writes.

The device DocType carries a health block that the heartbeat writes into on every beat: last heartbeat, app version, connectivity, last SMS timestamp, battery, charging state, and the pending and failed queue depths reported by the phone. That means an ordinary Frappe list view of devices, with no custom page and no chart, is a usable fleet monitor. Somebody in the office can see which till has gone quiet, which one is on 4% battery, and which one has 200 messages stuck in its queue.

The heartbeat writes with update_modified=False, which is a small thing that matters at fleet scale. Without it, a device beating every five minutes touches the modified timestamp 288 times a day and makes every version-tracking and sync mechanism in Frappe think the record changed constantly.

Parse inline, then parse again on a schedule

The accept path stores the raw message and then tries to make sense of it, and the ordering there is a rule rather than a convenience:

doc.insert(ignore_permissions=True)

# Best-effort inline parse; the scheduled backstop retries anything left
# in "Received". A parse failure must never fail the accept.
try:
    parse_message(doc)
except Exception:
    frappe.log_error(title="Upeo SMS gateway parse failed", message=frappe.get_traceback())

frappe.db.commit()
return _json({"status": "accepted", "name": doc.name}, 200)

A parse failure must never fail the accept. If the parser throws, the phone must still be told the message was received, because otherwise the phone retries, and the retry hits the same broken parser, and now a bug in a regular expression is causing a message to bounce between a phone and a server forever. The SMS is safely stored; the parse is a separate concern that can be fixed and re-run.

Which is what the scheduled backstop does:

def process_pending_messages(limit=200):
    """Backstop: parse any messages still in 'Received' (inline parse missed)."""
    names = frappe.get_all(
        MESSAGE_DT, filters={"status": "Received"}, pluck="name",
        order_by="creation asc", limit=cint(limit),
    )
    for name in names:
        doc = frappe.get_doc(MESSAGE_DT, name)
        try:
            parse_message(doc)
        except Exception:
            doc.db_set("status", "Error", update_modified=False)
            doc.db_set("parse_error", frappe.get_traceback(with_context=False)[:500],
                       update_modified=False)
            frappe.log_error(...)
        frappe.db.commit()
    return {"processed": len(names)}

Anything still sitting in Received gets another go. If it fails again it moves to Error with a truncated traceback stored on the row itself, which is the difference between "somebody will find this in the error log eventually" and "there is a filtered list view of messages that need a human". The commit is inside the loop so one poisonous message does not roll back the batch.

Status transitions of a stored gateway message A message is created with status Received. The inline parse moves it to Parsed if it is an M-Pesa credit confirmation, or to Ignored if it is any other kind of message such as a balance or debit notification. If the parse throws, the message stays in Received until the scheduled backstop retries it, and a second failure moves it to Error with a stored traceback. Received stored, accept returned "received" and "confirmed" Parsed linked to an M-Pesa Payment anything else Ignored balance, debit, promotions parse threw twice: inline, then the backstop Error traceback stored on the row Nothing on this diagram can put the message back in the phone's queue. The accept already happened, so every branch here is a server-side concern.
Four states, and only one of them needs a person. That is the ratio you want.

The parser itself is conservative on purpose. It only treats a message as a payment if the body contains both "received" and "confirmed", so balance messages, debit notifications and marketing SMS end up as Ignored rather than being coerced into a payment with a zero amount:

low = text.lower()
if "received" not in low or "confirmed" not in low:
    return None

It is not a complete parser for every Safaricom message format and it is not trying to be. It covers the credit confirmations that a till actually receives and deliberately ignores the rest, which is a much easier thing to keep correct than a parser that claims to handle everything.

The punchline: one payment table, two sources

Here is the part that makes the whole integration worth building rather than bolting a second payment system onto the side of the first.

name, _created = mpesa.upsert_mpesa_payment(
    transaction_code=parsed["code"],
    amount=parsed["amount"],
    phone_number=parsed.get("phone"),
    payer_name=parsed.get("name"),
    transaction_time=parsed.get("time"),
    raw_payload=doc.message,
    source="SMS Gateway",
    gateway_message=doc.name,
)

That function is not part of the SMS gateway. It is the same function the Daraja webhook calls, and it deduplicates on a unique transaction code:

def upsert_mpesa_payment(transaction_code, amount=0, ...):
    """Idempotently create an Upeo Retail M-Pesa Payment.

    Shared by the Daraja webhook and the SMS-gateway parser. Dedups on the
    unique transaction_code; returns (name, created).
    """
    if not transaction_code:
        frappe.throw(_("transaction_code is required."))
    if frappe.db.exists(MPESA_DT, transaction_code):
        return transaction_code, False
    ...

So a shop can start on the SMS gateway, get approved for Daraja six months later, and run both simultaneously through the transition without producing a single duplicate payment, because the transaction code is the transaction code no matter which door it came through. Reconciliation, matching against invoices, the whole downstream flow: one code path, one set of bugs, one thing to test. The source field records which door, which is useful for reporting and irrelevant to correctness.

That design decision is the subject of its own article, because it is the reason the phone stays dumb.

Housekeeping

Two scheduled jobs keep the thing from growing without bound. process_pending_messages runs on a cron entry as the parse backstop. prune_old_nonces runs daily and drops consumed nonces well past the five-minute freshness window:

def prune_old_nonces(hours=24):
    """Daily: drop consumed nonces well past the freshness window."""
    from frappe.utils import add_to_date

    cutoff = add_to_date(now_datetime(), hours=-cint(hours))
    frappe.db.delete(NONCE_DT, {"created_at": ["<", cutoff]})
    frappe.db.commit()

The replay ledger only has to remember a nonce for longer than the window in which a stale timestamp would be rejected anyway. Keeping 24 hours against a 5-minute window is a margin of nearly three hundred, which costs almost nothing and removes any chance of the two schedules interacting badly.

What I would do differently

The path rewrite is clever, and clever is a warning sign. It works, it is commented, and it is three lines, but it depends on an implementation detail of when Frappe reads the request path and on werkzeug caching a property. Both could change in a version bump, and the failure would be a 404 on a payment endpoint, discovered by a shop rather than by a test. If I were starting again I would push harder on the phone side to make the base URL include the full method path, so that the operator types a longer URL once and no rewrite is needed at all. The reason it works this way is that the contract was written against a generic backend before ERPNext was the target, which is a very ordinary way for a wart to happen.

The endpoints also have no rate limiting of their own. They are cheap to reject, the first thing they do is a device lookup, and the signature check comes before any write. But an unauthenticated caller can still make Frappe do a database read per request, and the only thing standing between that and a bad afternoon is the reverse proxy. A per-device counter in the nonce table would be a natural place to add it.

And I would add a test that asserts the canonical string on the Python side against the same literal the Dart test uses. Today the two implementations are kept in step by a comment saying they must be. That has held, because the module is small and the comment is loud. It is still weaker than the Dart side, which has a test asserting the exact bytes, and the asymmetry is not a good look.

Frequently asked questions

Why does my custom path under /api/ return 404 in Frappe?
Because Frappe hands every path beginning with /api/ to its own API handler before website routing gets a chance, and that handler only understands method, resource, v1 and v2. A path like /api/sms/incoming matches none of them and never reaches your app. The fix is a before_request hook that rewrites the path onto a whitelisted dotted method.
Is it safe to use allow_guest=True on a Frappe endpoint?
It is safe when the endpoint carries its own authentication. allow_guest only means Frappe will not require a session; it does not mean the endpoint is unauthenticated. Here every request must present a valid HMAC signature over a canonical string, a sent_at inside a five minute window, and an unused nonce. A caller who has none of those gets a 401 regardless of session state.
How do I return a plain JSON body and my own status code from Frappe?
Return a werkzeug Response object directly. Frappe wraps ordinary return values in its own envelope, typically under a message key, and maps exceptions onto its own status codes. Constructing the Response yourself means the client sees exactly the status and body your contract specifies, which matters when the client is a third party you cannot change.
How should a secret be stored on a DocType?
As a Password fieldtype, then read with frappe.utils.password.get_decrypted_password. Frappe keeps Password fields out of the document table and encrypted at rest, so the secret does not appear in a list view, an export, or an ordinary get_doc. Storing it in a Data field puts it in plain sight of anybody with read access to the DocType.
#Frappe#ERPNext#Python#HMAC#M-Pesa#API Design
Keep reading

Related articles