Webhooks
Your webhook is an HTTPS endpoint that receives signed calls from the platform and answers with JSON.
Each command, card button and health check is one POST.
The request
POST /banga/hook HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: Banga-Apps/1
X-Banga-App: 3JkD7SR2o281QXh46yvD9arjgFC
X-Banga-Delivery: 3JkE0q9v2mW7cA1sE9tYpL3rVd4
X-Banga-Timestamp: 1790179200
X-Banga-Signature: v1=6f3c5e...9a
{
"type": "invocation",
"id": "3JkE0pX4n1Qy8LzT6bHc2VwR5sA",
"ts": 1790179200,
"app": { "id": "3JkD7SR2o281QXh46yvD9arjgFC", "version": 3, "manifestHash": "9f2b..." },
"command": "lunch.start",
"flow": null,
"step": null,
"values": { "restaurant": "bkk", "time": "12:30" },
"sealed": null,
"payload": null,
"action": null,
"user": { "id": "Qk9X7...", "name": "Dima T." },
"conversation": { "ref": "Zr41c...", "roomType": "PRIVATE_GROUP" }
}
| Header | Meaning |
|---|---|
X-Banga-App | Your app's id. |
X-Banga-Delivery | A unique id for this call. Refuse one you have seen before. |
X-Banga-Timestamp | When the call was signed, in seconds since 1970 (UTC). |
X-Banga-Signature | v1= and the hex HMAC of the call. During a secret rotation
it carries two values separated by a comma, one for each secret. |
| Body field | Meaning |
|---|---|
type | invocation, or ping for a health check. |
id | The invocation id. It stays the same across the steps of one multi-step form. |
ts | When the call was made, in seconds. |
app | Your app's id, the manifest version the user has, and its
manifestHash. |
command | The id of the command the user ran. |
flow | Set instead of command when a card's open_flow button
started the form. |
step | For a dynamic flow, the id of the view these answers come from. |
values | The answers, keyed by input id. Checked against your manifest before they reach you. |
sealed | Encrypted answers, when your manifest has a sealing key. values is then
null. See Sealed answers. |
payload | With flow: the payload of the open_flow button,
on every step. See Flow payload. |
action | {"id", "payload"} when a card's submit button was
tapped. command and flow are then null. |
user | id, the per-app id, always. name,
language and location only under their scopes. |
conversation | roomType of the room it happened in, and ref under
the conversation.ref scope. Test console calls have no room. |
The per-app id stays the same while the user has your app, including if they remove it and install it again. Only a user who removes the app and asks to be forgotten comes back with a new id. You and your testers get a different id before installing the app than after.
Verifying a call
Check every call before trusting it. Use the raw request body, exactly as received, before any JSON parsing, and
the whole secret, including whsec_, as the key.
expected = hex(HMAC-SHA256(secret, X-Banga-Timestamp + "." + rawBody))
accept = expected equals one of the v1= values of X-Banga-Signature (compared in constant time)
and |now - X-Banga-Timestamp| <= 300 seconds
and X-Banga-Delivery was not accepted in the last 10 minutes
Python
import hashlib, hmac, time
SECRET = b"whsec_..." # the whole secret, including "whsec_"
seen = {} # delivery id -> when it may be forgotten
def is_authentic(headers, raw_body: bytes) -> bool:
ts = headers.get("X-Banga-Timestamp", "")
delivery = headers.get("X-Banga-Delivery", "")
presented = [p.strip()[3:] for p in headers.get("X-Banga-Signature", "").split(",") if p.strip().startswith("v1=")]
if not ts.isdigit() or not delivery or abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(SECRET, ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, p) for p in presented):
return False
now = time.time()
for key in [k for k, forget_at in seen.items() if forget_at < now]:
del seen[key]
if delivery in seen:
return False
seen[delivery] = now + 600
return True
Java
static boolean hasValidSignature(String secret, String timestamp, String signatureHeader, byte[] rawBody)
throws GeneralSecurityException {
if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > 300) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.US_ASCII);
for (String part : signatureHeader.split(",")) {
String value = part.trim();
if (value.startsWith("v1=")
&& MessageDigest.isEqual(expected, value.substring(3).getBytes(StandardCharsets.US_ASCII))) {
return true;
}
}
return false;
}
// Then refuse an X-Banga-Delivery you have already accepted in the last 10 minutes.
Node.js is in the Getting started example.
Rotating the secret. The Webhook tab's Rotate secret shows a new secret once. The old one keeps working for 24 hours, and during that time each call carries a signature for each, so you can roll the new secret out without dropping calls.
Replies
Answer with status 200, a JSON content type and a body of at most
32768 bytes, within 5
seconds. The reply is one of four types.
A card
{ "type": "card", "visibility": "private", "card": { "blocks": [ { "type": "title", "text": "Done" } ] } }
Build it inline or from a template. See Cards.
A short message
{ "type": "ack", "message": "Thanks, noted." }
Shown to the user as a brief notice. Up to 120 characters.
An error for the user
{ "type": "error", "userMessage": "That restaurant is closed today.", "code": "closed" }
Shown to the user as it is, so write it for them. Up to 200 characters, with an optional code of up to
64 characters for your own logs. Use it for expected problems, such as a closed shop or a sold-out slot.
The next view
Only in a dynamic flow.
{ "type": "view", "view": { "id": "slot", "title": "Pick a slot", "blocks": [ ... ] } }
A payment request
Asks the user to pay your app; needs the payments scope. The user sees a native sheet with your
description and the amount and confirms on their device; you then receive a signed payment.completed
(or payment.declined / payment.expired) event at this same webhook. An optional
card next to it is shown once the sheet is answered. See Payments.
{ "type": "payment",
"payment": { "amount": "5.00", "currency": "USDBG", "reference": "evt-42/seat-17",
"description": "Ticket - Rooftop meetup", "expiresIn": 600 } }
Your webhook receives payment events with the same headers and signature as an invocation. Answer 2xx;
anything else is retried on a schedule with the same X-Banga-Delivery id, so deduplicate on it. Your
reply to a payment.completed event may be a private card (a receipt), an ack
or an error, shown to the payer when the first attempt reaches you in time.
{ "type": "payment.completed", "id": "...", "ts": 1758542671, "app": { "id": "..." },
"payment": { "id": "...", "kind": "charge", "status": "completed", "amount": "5", "currency": "USDBG",
"reference": "evt-42/seat-17", "description": "Ticket - Rooftop meetup",
"createdAt": 1758542600, "completedAt": 1758542671 },
"user": { "id": "u_p9Kx..." } }
When a call fails
- A status from 400 to 499 is recorded as your app's error. The user sees a general message that the app's reply
could not be shown. Use a
{"type": "error"}reply when you have something to tell them. - A status of 500 or more, a timeout, a redirect, a body that is not JSON or is too large, or a reply that breaks the contract all count as failures. The user is told the app is not answering, or that its reply could not be shown.
- After 5 failures within 60 seconds, your app is not called for 60 seconds, and users are told it is not answering. The first call after that decides whether it stays closed.
- At most 8 calls to your app run at the same time. More are refused rather than queued.
Every call is on the app's Activity tab, with its outcome, status, time taken and sizes, and the reason when it failed. Rows are kept for 30 days and can be downloaded as CSV. If your app needs more time than the usual budget, for example because it calls a language model, ask a site administrator. They can give one app up to 120 seconds.
Multi-step forms
In a flow with "mode": "dynamic", the phone shows the flow's first view and sends its answers with
step set to that view's id. Your webhook answers with a card, message or error to finish, or with the
next view:
{ "type": "view", "view": { "id": "slot", "title": "Pick a slot", "blocks": [ ... ] } }
The phone shows it and sends its answers the same way, with the same invocation id. Rules:
- The view you return must be declared in the flow, with the same
id. Its answers are checked against the manifest's declaration of that view. You can change the title, texts and defaults you send back, but not the inputs' rules. - Each call carries only that view's answers. Keep earlier answers on your server, keyed by the invocation id.
- One invocation may take at most 8 steps.
Card actions
When someone taps a submit button on one of your cards, your webhook gets one call as that person:
{ "type": "invocation", "command": null, "flow": null, "values": {}, "action": { "id": "close", "payload": { "order": "4821" } }, ... }
The tapper may not be the person who posted the card. Check that they are allowed to do what the button does, for example that only the organiser may close an order.
Flow payload
When an open_flow button started the form, every call of that form carries the button's payload next to
the answers:
{ "type": "invocation", "command": null, "flow": "add", "values": { "dish": "Pad Thai" }, "payload": { "order": "4821" }, ... }
Keys that are input ids of the flow also pre-fill those inputs, and then arrive in values as the user left
them. Only values is checked against your manifest. The platform checks just the payload's size, and
it is never sealed. Like a submit payload, it comes from the tapper's phone, so check it before you act
on it, for example that order 4821 exists and this person may add to it.
Sealed answers
If form answers are sensitive, add your own public key to the manifest. Phones then encrypt the answers to it, and the platform passes them on without being able to read them.
# pip install cryptography
import base64
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
key = X25519PrivateKey.generate() # keep it secret, on your server
public = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
print(base64.b64encode(public).decode()) # goes into "sealing": { "publicKey": ... }
Your webhook then receives "values": null and:
"sealed": { "alg": "x25519-hkdf-sha256-aes256gcm/v1", "value": "..." }
value is base64url, without padding, of the phone's one-time X25519 public key (32 bytes), a 12-byte
nonce, and the AES-256-GCM ciphertext with its tag. The key is HKDF-SHA256 of the shared X25519 secret, with an
empty salt and the info banga-apps/sealed/v1. The associated data is your app id. The plaintext is
{"values": {...}}.
import base64, json
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def open_sealed(private_key: X25519PrivateKey, app_id: str, value: str) -> dict:
data = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
ephemeral, nonce, box = data[:32], data[32:44], data[44:]
shared = private_key.exchange(X25519PublicKey.from_public_bytes(ephemeral))
key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"banga-apps/sealed/v1").derive(shared)
plain = AESGCM(key).decrypt(nonce, box, app_id.encode())
return json.loads(plain)["values"]
Since the platform cannot read sealed answers, it cannot check them either. Validate them yourself.
Health checks
Every 5 minutes, the platform sends a signed
{"type": "ping", "ts": ...} to apps that were used in the last
24 hours. Answer {"type": "pong"}. Two failed checks
in a row mark the app as down in the app directory until it answers again.