Mihe

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_…"
}
  • input has already been validated against the action's inputSchema.
  • user.id is the sub of the token your backend signed.
  • user.permissions is 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.

Node (Express)
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
});
Python
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=62bd01fa522ea2ea9c75b505803372e68ad8c9b2a0bd9ba22cd2ec2b20ce43ac

If 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.

On this page