Uno Answers documentation
Uno Answers is the parent app and client registry for the platform. It receives all Instagram webhooks from Meta, works out which client each event belongs to, and forwards the event to that client's app.
Meta (Instagram) ──► POST /api/instagram/webhook (signature verified)
│
├─ entry.id ─► clients.instagram_account_id ─► client
├─ log ─► webhook_logs
└─ shared mode ─► POST https://{subdomain}.askuno.com/api/internal/process-webhook
(signed with the client's webhook secret)
Shared mode clients use the Uno Answers Meta app (App ID 1634590611674345). Own mode clients use their own Meta app and receive webhooks from Meta directly.
API reference
All request and response bodies are JSON. Errors have the shape {"error": "message", "details": [...]}. Admin endpoints require the X-API-Key header. API routes are rate-limited to 100 requests per 15 minutes per IP; Meta callbacks are exempt.
Public
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health | None | Liveness check. Returns status, version and uptime. |
| GET | /api/instagram/webhook | Verify token | Meta subscription verification. Echoes hub.challenge. |
| POST | /api/instagram/webhook | X-Hub-Signature-256 | Receives all Instagram events and routes each one to the owning client. |
| POST | /api/auth/data-deletion | signed_request | Meta data-deletion callback. Returns a status URL and confirmation code. |
| GET | /api/auth/data-deletion/status/:code | None | JSON status of a deletion request. |
| POST | /api/auth/deauthorize | signed_request | Meta deauthorize callback (an influencer removed the app). |
| GET | /api/tenant | None | Public info for the client on the current subdomain. |
Client apps
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/client/me | X-Client-Key | A client app reads its own registry record. |
Admin
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/admin/stats | X-API-Key | Client counts and 24h webhook volume / error rate. |
| GET | /api/admin/clients | X-API-Key | List clients. Query: limit, offset, search, active. |
| POST | /api/admin/clients | X-API-Key | Create a client. Returns the webhook secret and client API key once. |
| GET | /api/admin/clients/:id | X-API-Key | Client detail, Meta app config and 24h webhook stats. |
| PUT | /api/admin/clients/:id | X-API-Key | Update any subset of fields. |
| DELETE | /api/admin/clients/:id | X-API-Key | Deactivate (soft delete). Events for the client stop being routed. |
| POST | /api/admin/clients/:id/regenerate-secret | X-API-Key | Rotate the webhook signing secret. |
| POST | /api/admin/clients/:id/regenerate-api-key | X-API-Key | Rotate the client API key. |
| GET | /api/admin/clients/:id/webhook-logs | X-API-Key | Event log for one client. Query: status, limit, offset. |
| GET | /api/admin/webhook-logs | X-API-Key | Event log across all clients. |
Create a client
curl -X POST https://unoanswers.com/api/admin/clients \
-H "X-API-Key: $INTERNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "HDFC Bank",
"subdomain": "hdfc",
"email": "marketing@hdfc.example",
"mode": "shared",
"instagram_account_id": "17841400000000000"
}'
Response 201:
{
"client": { "id": "4d3c…", "name": "HDFC Bank", "subdomain": "hdfc", "mode": "shared",
"url": "https://hdfc.askuno.com", "is_active": true, … },
"credentials": {
"webhook_secret": "9f2c…", // verifies forwards from Uno Answers
"internal_api_key": "51ab…" // X-Client-Key for calls to the registry
},
"warning": "Store these credentials now. They cannot be retrieved again, only regenerated."
}
Webhook events
Subscribe the Meta app's Instagram webhook to https://unoanswers.com/api/instagram/webhook with verify token ASK_UNO_VERIFY_TOKEN. Supported fields:
| Field | Trigger | Routed by |
|---|---|---|
comments | Someone comments on a post caption | entry.id |
live_comments | Someone comments during a Live broadcast | entry.id |
messages | Someone sends the account a DM | entry.id (= recipient.id) |
Other fields are logged and not forwarded. Example comments payload from Meta:
{
"object": "instagram",
"entry": [{
"id": "17841400000000000", // the client's Instagram account
"time": 1726650000,
"changes": [{
"field": "comments",
"value": {
"id": "18012345678901234",
"text": "DEALS please!",
"from": { "id": "5566778899", "username": "aarav.k" },
"media": { "id": "17999999999999999", "media_product_type": "FEED" }
}
}]
}]
}
Forwarding to clients
Each supported event is forwarded individually, in the same Meta envelope, to POST https://{subdomain}.askuno.com/api/internal/process-webhook with these headers:
| Header | Value |
|---|---|
X-Uno-Client-Id | Client UUID |
X-Uno-Timestamp | Unix time in milliseconds |
X-Uno-Signature | sha256=HMAC_SHA256(webhook_secret, timestamp + "." + rawBody) |
Client apps must verify the signature and reject timestamps more than 5 minutes old. Failed forwards (network errors, 429, 5xx) are retried 3 times with exponential backoff. Data-deletion and deauthorize notices are forwarded to /api/internal/data-deletion and /api/internal/deauthorize with the same signing.
// Client app (Express) — verify a forward from Uno Answers
import crypto from 'node:crypto';
function verifyUno(req, secret) {
const ts = req.get('X-Uno-Timestamp');
if (!ts || Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(ts + '.' + req.rawBody).digest('hex');
const given = req.get('X-Uno-Signature') || '';
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Database schemas
The registry runs on Supabase Postgres. The full migration is in database/migrations/001-init.sql.
| Table | Purpose | Key columns |
|---|---|---|
clients | Registry of connected brands | subdomain (unique), instagram_account_id (unique), mode, webhook_secret_encrypted, internal_api_key_hash, is_active |
meta_app_configs | Meta app settings per client | client_id (unique), app_id, webhook_url, oauth_redirect_uris |
webhook_logs | Audit trail of every event | client_id, event_type, sender_id, payload (jsonb), status (received / processed / error), attempts |
data_deletion_requests | GDPR deletion tracking | confirmation_code, status, clients_notified |
Row Level Security is enabled on every table with no public policies, so only the service role (this server) can read or write.
Deployment guide
- Run
database/migrations/001-init.sqlin the Supabase SQL editor (orpsql "$DATABASE_URL" -f …). - Generate secrets:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # INTERNAL_API_KEY node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" # ENCRYPTION_KEY - Add every variable in
.env.exampleto Vercel (vercel env add NAME production). - Deploy with
vercel deploy --prodand checkGET /health. - In the Meta app dashboard, set the webhook callback URL to
/api/instagram/webhook, the Deauthorize callback to/api/auth/deauthorizeand the Data Deletion callback to/api/auth/data-deletion. Subscribe tocomments,live_commentsandmessages.
ENCRYPTION_KEY cannot be changed once clients exist: their stored webhook secrets would no longer decrypt. Back it up somewhere safe.Privacy & GDPR
When a user asks Meta to delete their data, Uno Answers:
- verifies the
signed_requestwith the app secret, - deletes every webhook log the user triggered from the registry,
- notifies every active shared-mode client app so it can delete campaign data,
- returns a confirmation code and a status page URL (
/data-deletion-status?code=…) to Meta.
Once every client has confirmed, the Instagram user ID is removed from the deletion record.
FAQ
Why does Meta keep retrying my webhook?
Meta retries any delivery that does not get a 200. The router returns 500 when the registry database is unreachable, so events are redelivered instead of lost. Check /health and the Supabase status.
Events arrive but are not forwarded to a client
Check the client's logs in the admin dashboard. No active client for Instagram account means the client's instagram_account_id doesn't match the entry.id Meta sends, or the client is deactivated.
Signature verification fails on every webhook
The router signs the raw request bytes with IG_APP_SECRET. Make sure the secret belongs to the same Meta app the webhook is subscribed on, and that no proxy rewrites the request body.
What happens in own mode?
Own-mode clients subscribe webhooks on their own Meta app, so Meta calls their app directly. Any own-mode events that do reach the router are logged but not forwarded.
How long are webhook logs kept?
Ninety days. Schedule SELECT cleanup_old_webhook_logs(); daily with Supabase pg_cron.