Verifying action calls
Check that a call to your backend came from Mihe, then check the user may make it.
When the assistant runs a backend action, the runtime POSTs to the action's
endpoint. Your endpoint must do three things before acting: verify the
signature, re-check the user's permissions from your own database, and take
tenancy from your own user record.
What arrives
POST /mihe/refund-order
Content-Type: application/json
X-Assistant-App: app_…
X-Assistant-Timestamp: 1800000000
X-Assistant-Signature: v1=62bd01fa…
{
"actionName": "refund_order",
"input": { "orderId": "1042", "reason": "duplicate" },
"user": { "id": "user_123", "permissions": [] },
"conversationId": "conv_…"
}inputhas already been validated against the action'sinputSchema.user.idis thesubof the token your backend signed.user.permissionsis always empty, on purpose. Your endpoint looks the user's permissions up itself.
1. Verify the signature
The signature is HMAC-SHA256, keyed with the app's secret key, over the timestamp, a full stop, and the raw request body:
v1=hex(HMAC-SHA256(secret_key, "{timestamp}.{raw body}"))Refuse the call if the signature does not match, or if the timestamp is more than five minutes from your clock, which is what stops a captured request being replayed.
Verify the raw body, as bytes, before parsing it. Re-serialising parsed JSON makes the check depend on key order and number formatting, which passes by luck, not by construction.
import crypto from "node:crypto";
import express from "express";
app.post("/mihe/refund-order", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = Number(req.header("x-assistant-timestamp"));
const signature = req.header("x-assistant-signature") ?? "";
const body = req.body.toString("utf8");
const expected =
"v1=" +
crypto
.createHmac("sha256", process.env.MIHE_SECRET_KEY!)
.update(`${timestamp}.${body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - timestamp) <= 300;
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!fresh || !valid) return res.status(401).send("bad signature");
const call = JSON.parse(body);
// …steps 2 and 3, then act
});import hashlib, hmac, os, time
def verify(raw_body: bytes, timestamp: str, signature: str) -> bool:
expected = "v1=" + hmac.new(
os.environ["MIHE_SECRET_KEY"].encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
fresh = abs(time.time() - int(timestamp)) <= 300
return fresh and hmac.compare_digest(expected, signature)Compare in constant time (timingSafeEqual, compare_digest). An ordinary
string comparison leaks how much of a guess was right.
Check your implementation
With the secret sk_live_customer_secret, the timestamp 1800000000 and this
exact body:
{"actionName":"get_orders","input":{"limit":5}}the signature must be:
v1=62bd01fa522ea2ea9c75b505803372e68ad8c9b2a0bd9ba22cd2ec2b20ce43acIf yours differs, the separator, the order or the encoding is wrong.
2. Re-check permissions
Look the user up by user.id and check, from your own database, that they may
do this. The runtime has already checked the permissions in their token, but
your endpoint is the last place that can still say no, and a registry's
requirements can be changed from the dashboard.
3. Take tenancy from your records
If your app has tenants, take the tenant from the user's record, never from anything in the request. Mihe knows nothing of your tenancy and must not be able to influence it.
Responding
Answer 2xx with JSON, and it becomes the result the model reads, so return
something it can describe to the user:
{ "refunded": true, "amount": "48.00", "currency": "USD" }Anything else is a failure, and the model is shown your status and the first 300 characters of your response body so it can explain what went wrong. Keep error text plain, and never put secrets or other users' data in it.