Docs

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

MethodPathAuthDescription
GET/healthNoneLiveness check. Returns status, version and uptime.
GET/api/instagram/webhookVerify tokenMeta subscription verification. Echoes hub.challenge.
POST/api/instagram/webhookX-Hub-Signature-256Receives all Instagram events and routes each one to the owning client.
POST/api/auth/data-deletionsigned_requestMeta data-deletion callback. Returns a status URL and confirmation code.
GET/api/auth/data-deletion/status/:codeNoneJSON status of a deletion request.
POST/api/auth/deauthorizesigned_requestMeta deauthorize callback (an influencer removed the app).
GET/api/tenantNonePublic info for the client on the current subdomain.

Client apps

MethodPathAuthDescription
GET/api/client/meX-Client-KeyA client app reads its own registry record.

Admin

MethodPathAuthDescription
GET/api/admin/statsX-API-KeyClient counts and 24h webhook volume / error rate.
GET/api/admin/clientsX-API-KeyList clients. Query: limit, offset, search, active.
POST/api/admin/clientsX-API-KeyCreate a client. Returns the webhook secret and client API key once.
GET/api/admin/clients/:idX-API-KeyClient detail, Meta app config and 24h webhook stats.
PUT/api/admin/clients/:idX-API-KeyUpdate any subset of fields.
DELETE/api/admin/clients/:idX-API-KeyDeactivate (soft delete). Events for the client stop being routed.
POST/api/admin/clients/:id/regenerate-secretX-API-KeyRotate the webhook signing secret.
POST/api/admin/clients/:id/regenerate-api-keyX-API-KeyRotate the client API key.
GET/api/admin/clients/:id/webhook-logsX-API-KeyEvent log for one client. Query: status, limit, offset.
GET/api/admin/webhook-logsX-API-KeyEvent 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."
}
Instagram account IDs are larger than JavaScript's safe integer limit. Always send and store them as strings.

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:

FieldTriggerRouted by
commentsSomeone comments on a post captionentry.id
live_commentsSomeone comments during a Live broadcastentry.id
messagesSomeone sends the account a DMentry.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:

HeaderValue
X-Uno-Client-IdClient UUID
X-Uno-TimestampUnix time in milliseconds
X-Uno-Signaturesha256=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.

TablePurposeKey columns
clientsRegistry of connected brandssubdomain (unique), instagram_account_id (unique), mode, webhook_secret_encrypted, internal_api_key_hash, is_active
meta_app_configsMeta app settings per clientclient_id (unique), app_id, webhook_url, oauth_redirect_uris
webhook_logsAudit trail of every eventclient_id, event_type, sender_id, payload (jsonb), status (received / processed / error), attempts
data_deletion_requestsGDPR deletion trackingconfirmation_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

  1. Run database/migrations/001-init.sql in the Supabase SQL editor (or psql "$DATABASE_URL" -f …).
  2. 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
  3. Add every variable in .env.example to Vercel (vercel env add NAME production).
  4. Deploy with vercel deploy --prod and check GET /health.
  5. In the Meta app dashboard, set the webhook callback URL to /api/instagram/webhook, the Deauthorize callback to /api/auth/deauthorize and the Data Deletion callback to /api/auth/data-deletion. Subscribe to comments, live_comments and messages.
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:

  1. verifies the signed_request with the app secret,
  2. deletes every webhook log the user triggered from the registry,
  3. notifies every active shared-mode client app so it can delete campaign data,
  4. 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.