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" }
}
HeaderMeaning
X-Banga-AppYour app's id.
X-Banga-DeliveryA unique id for this call. Refuse one you have seen before.
X-Banga-TimestampWhen the call was signed, in seconds since 1970 (UTC).
X-Banga-Signaturev1= and the hex HMAC of the call. During a secret rotation it carries two values separated by a comma, one for each secret.
Body fieldMeaning
typeinvocation, or ping for a health check.
idThe invocation id. It stays the same across the steps of one multi-step form.
tsWhen the call was made, in seconds.
appYour app's id, the manifest version the user has, and its manifestHash.
commandThe id of the command the user ran.
flowSet instead of command when a card's open_flow button started the form.
stepFor a dynamic flow, the id of the view these answers come from.
valuesThe answers, keyed by input id. Checked against your manifest before they reach you.
sealedEncrypted answers, when your manifest has a sealing key. values is then null. See Sealed answers.
payloadWith 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.
userid, the per-app id, always. name, language and location only under their scopes.
conversationroomType 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.