Webhooks

Receive real-time, signed HTTP POST notifications when events occur in your application — auth, waitlist, feedback, and payments. Configure webhooks per app in the dashboard under Applications → [Your App] → Webhook. Each endpoint can subscribe to its own set of events, and gets its own signing secret (shown once at creation).

Set up a webhook

Webhooks are available on the Pro and Business plans. On a lower plan you can neither add an endpoint nor receive deliveries — existing endpoints are kept and resume automatically when you upgrade.

  1. In the dashboard, open Applications → [Your App] → Webhook.
  2. Enter your endpoint URL. It must be https:// and resolve to a public address — http://, localhost and private/loopback IPs are rejected.
  3. Tick the events you want to receive.
  4. Click Add webhook. Onelo shows your signing secret once — copy it immediately and store it as a secret (e.g. an env var). It is encrypted at rest and can never be retrieved again.
  5. Verify the signature on every request (see below) and respond 200 within 5 seconds.
You can add multiple endpoints per app, each subscribing to a different set of events and each with its own secret — e.g. one endpoint for payment.* routed to your billing system, another for user.* to your CRM. To change an endpoint's events or secret, delete it and create a new one. For local development, expose your endpoint through an HTTPS tunnel (e.g. ngrok) — localhost is not reachable.

Envelope

Every webhook request shares the same top-level structure.

json
{
  "id": "evt_3a7f2b9c1d4e5f6a",
  "event": "waitlist.joined",
  "timestamp": "2026-06-13T17:17:45.736+00:00",
  "api_version": "2026-06-01",
  "app": {
    "id": "b3c4d5e6-f7a8-...",
    "name": "Turingo",
    "type": "mobile"
  },
  "data": { ... }
}
FieldTypeDescription
idstringUnique delivery ID. Identical to the X-Onelo-Delivery header. Use for idempotency.
eventstringEvent type, e.g. waitlist.joined
timestampISO 8601UTC time the event occurred
api_versionstringEnvelope schema version. Bumped on breaking changes. Current: 2026-06-01
app.idUUIDYour application ID
app.namestringYour application name
app.typestringweb / mobile / desktop / server
dataobjectEvent-specific payload (see below)
Size cap. Deliveries are capped at 256 KB. If a payload would exceed that, the envelope is still delivered but data is replaced with { "_truncated": true, "reason": "payload_too_large" } — check for that flag before reading fields, and fetch the full record from the API when you see it.

HTTP Headers

HeaderValue
Content-Typeapplication/json
X-Onelo-EventEvent type, e.g. waitlist.joined
X-Onelo-TimestampUnix epoch seconds when this delivery was signed — part of the signature
X-Onelo-Signaturesha256=<hmac> — see Verifying Signatures below
X-Onelo-DeliverySame as id in the envelope — use for deduplication

Verifying Signatures

The signature is an HMAC-SHA256 over `${X-Onelo-Timestamp}.${rawBody}` — the timestamp and a literal dot prepended to the raw request body — keyed by your webhook secret. Binding the timestamp into the signed content gives replay protection. Always compare with a timing-safe function, and verify against the raw body bytes (not a re-serialized object — key order and whitespace must match exactly).

Node.js
const crypto = require('crypto');

function verify(secret, rawBody, signature, timestamp) {
  const signedPayload = timestamp + '.' + rawBody;       // `${ts}.${body}`
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// signature = req.header('X-Onelo-Signature')
// timestamp = req.header('X-Onelo-Timestamp')
// rawBody   = the exact bytes received (e.g. express.raw())
Python
import hmac, hashlib

def verify(secret: str, raw_body: bytes, signature: str, timestamp: str) -> bool:
    signed = f"{timestamp}.".encode() + raw_body
    expected = 'sha256=' + hmac.new(
        secret.encode(), signed, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)
n8n — Code node
const crypto = require('crypto');
const secret = process.env.ONELO_WEBHOOK_SECRET;
const headers = $input.item.json.headers;
const sig = headers['x-onelo-signature'];
const ts  = headers['x-onelo-timestamp'];
const body = JSON.stringify($input.item.json.body); // see caveat below
const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(ts + '.' + body)
  .digest('hex');
if (sig !== expected) throw new Error('Invalid signature');
return $input.item;
The n8n example re-serializes the parsed body with JSON.stringify, which can differ from the bytes Onelo signed (key order / spacing). If verification fails, capture the raw request body instead of the parsed JSON.

Receiving events (Express)

A complete handler: capture the raw body (signature verification needs the exact bytes), verify, respond 200 fast, then process out of band.

Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();

const SECRET = process.env.ONELO_WEBHOOK_SECRET;

// IMPORTANT: raw body — do NOT use express.json() on this route.
app.post('/onelo/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body;                      // Buffer
  const sig = req.header('X-Onelo-Signature');
  const ts  = req.header('X-Onelo-Timestamp');

  const expected = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(ts + '.' + rawBody.toString('utf8'))
    .digest('hex');

  if (!sig || sig.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(400).send('invalid signature');
  }

  const event = JSON.parse(rawBody.toString('utf8'));

  // Respond immediately (5s timeout), then do slow work asynchronously.
  res.sendStatus(200);

  // Deduplicate on event.id — retries reuse the same id — then handle by type.
  if (alreadyProcessed(event.id)) return;
  switch (event.event) {
    case 'payment.succeeded': /* ... */ break;
    case 'waitlist.joined':   /* ... */ break;
    // ...
  }
});

Authentication Events

user.createdNew user registered through the SDK’s email sign-up — hosted-page and OAuth registrations arrive as user.signed_in instead
{
  "user": {
    "id": "91664f86-798c-4635-a7f8-bb3c68bc7f62",
    "email": "[email protected]",
    "plan": null,
    "metadata": {},
    "created_at": "2026-06-13T17:17:45+00:00"
  },
  "auth_method": "email",
  "session": { "expires_at": "2026-07-13T17:17:45+00:00" },
  "request": {
    "ip_hash": "sha256=a3f8...",
    "user_agent": "Turingo/1.0 iOS/18.0",
    "platform": "mobile"
  }
}
user.signed_inUser authenticated successfully
{
  "user": {
    "id": "91664f86-798c-4635-a7f8-bb3c68bc7f62",
    "email": "[email protected]",
    "plan": "pro",
    "metadata": { "company": "Acme" },
    "created_at": "2026-04-01T10:00:00+00:00"
  },
  "auth_method": "hosted_oauth",
  "session": { "expires_at": "2026-07-13T17:17:45+00:00" },
  "request": {
    "ip_hash": "sha256=a3f8...",
    "user_agent": "Turingo/1.0 iOS/18.0",
    "platform": "mobile"
  }
}

auth_method: email or hosted_oauth (Google, GitHub, Apple)

user.signed_outUser signed out — fires for Onelo SDK sessions
{
  "user": { "id": "91664f86-...", "email": "[email protected]" }
}
user.password_changedPassword reset confirmed
{
  "user": { "id": "91664f86-...", "email": "[email protected]" },
  "changed_at": "2026-06-13T17:17:45+00:00"
}
user.deletedAccount deleted (GDPR hard-delete or dashboard removal)
{
  "user": { "id": "91664f86-...", "email": "[email protected]" },
  "deleted_at": "2026-06-13T17:17:45+00:00"
}

Waitlist Events

waitlist.joinedSomeone joined the waitlist
{
  "email": "[email protected]",
  "name": "Jan Kowalski",
  "position": 42,
  "referrer": "producthunt",
  "metadata": { "company": "Startup" },
  "source": "widget",
  "waitlist_id": "uuid-or-null",
  "joined_at": "2026-06-13T17:17:45+00:00",
  "request": { "ip_hash": "sha256=a3f8..." }
}

source: hosted_page (the Onelo-hosted waitlist page or its iframe embed), widget (embedded widget) or api (direct SDK call)

waitlist.invitedInvite sent to a waitlist entry
{
  "email": "[email protected]",
  "position": 42,
  "invited_at": "2026-06-13T17:17:45+00:00"
}
waitlist.redeemedUser redeemed their invite token
{
  "email": "[email protected]",
  "redeemed_at": "2026-06-13T17:17:45+00:00"
}

See the Waitlist guide for how signups, invites, and redemptions fit together.

Feedback Events

feedback.submittedUser submitted feedback via SDK widget
{
  "id": "c4d5e6f7-...",
  "type": "bug",
  "title": "App crashes on launch",
  "description": "Steps to reproduce: tap the settings icon...",
  "status": "open",
  "area": "onboarding",
  "screenshot_url": "https://cdn.onelo.tools/screenshots/abc.png",
  "user_id": "91664f86-...",
  "submitted_at": "2026-06-13T17:17:45+00:00"
}

type: bug · feature_request · general. title is truncated to 120 characters and description to 500 (null when the reporter left it empty) — read the full report in the dashboard.

feedback.status_changedA team member changed a report's status in the dashboard
{
  "id": "c4d5e6f7-...",
  "type": "feature_request",
  "title": "Dark mode",
  "old_status": "under_review",
  "new_status": "planned",
  "user_id": "91664f86-...",
  "changed_at": "2026-06-13T17:17:45+00:00"
}

Payment Events

Emitted from the Onelo backend as it processes Stripe events. All monetary amounts are in the smallest currency unit (e.g. cents). user_id is your app user (or dashboard user) where resolvable, else null.

payment.succeededOne-time payment completed — subscription charges never emit this; they signal via subscription.created and invoice.paid
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "product": { "id": "prod-uuid", "amount": 4900, "currency": "usd" },
  "stripe_payment_intent_id": "pi_3N...",
  "paid_at": "2026-06-13T17:17:45+00:00"
}
payment.failedOne-time payment attempt failed
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "product": { "id": "prod-uuid", "amount": 4900, "currency": "usd" },
  "stripe_payment_intent_id": "pi_3N...",
  "failure_reason": "card_declined",
  "failed_at": "2026-06-13T17:17:45+00:00"
}
payment.refundedPayment refunded (full or partial)
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "product": { "id": "prod-uuid", "amount": 4900, "currency": "usd" },
  "refund_amount": 4900,
  "stripe_charge_id": "ch_3N...",
  "refunded_at": "2026-06-13T17:17:45+00:00"
}
payment.dispute_openedChargeback / dispute opened
{
  "user_id": "91664f86-...",
  "stripe_dispute_id": "dp_...",
  "stripe_charge_id": "ch_3N...",
  "amount": 4900,
  "currency": "usd",
  "reason": "fraudulent",
  "opened_at": "2026-06-13T17:17:45+00:00"
}
payment.dispute_resolvedDispute closed (won / lost / warning_closed)
{
  "stripe_dispute_id": "dp_...",
  "status": "won",
  "resolved_at": "2026-06-13T17:17:45+00:00"
}
subscription.createdNew subscription started (incl. trials)
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "product": { "id": "prod-uuid" },
  "stripe_subscription_id": "sub_...",
  "status": "trialing",
  "trial_ends_at": "2026-06-27T00:00:00+00:00",
  "cancel_at_period_end": false
}
subscription.updatedSubscription changed (status / plan / cancel flag)
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "product": { "id": "prod-uuid" },
  "stripe_subscription_id": "sub_...",
  "status": "active",
  "current_period_end": "2026-07-13T00:00:00+00:00",
  "cancel_at_period_end": false
}
subscription.cancelledSubscription cancelled / ended
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "product": { "id": "prod-uuid" },
  "stripe_subscription_id": "sub_...",
  "cancelled_at": "2026-06-13T17:17:45+00:00",
  "cancel_at_period_end": true
}
subscription.pausedSubscription paused
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "stripe_subscription_id": "sub_...",
  "paused_at": "2026-06-13T17:17:45+00:00"
}
subscription.resumedPaused subscription resumed
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "stripe_subscription_id": "sub_...",
  "resumed_at": "2026-06-13T17:17:45+00:00"
}
subscription.trial_endingTrial ends soon (~3 days before)
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "stripe_subscription_id": "sub_...",
  "trial_ends_at": "2026-06-16"
}
invoice.paidSubscription invoice paid — safe to provision
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "stripe_invoice_id": "in_...",
  "stripe_subscription_id": "sub_...",
  "amount_paid": 1900,
  "currency": "usd",
  "period_start": "2026-06-13T00:00:00+00:00",
  "period_end": "2026-07-13T00:00:00+00:00",
  "paid_at": "2026-06-13T17:17:45+00:00"
}
invoice.payment_failedSubscription invoice payment failed — Stripe will retry
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "stripe_invoice_id": "in_...",
  "stripe_subscription_id": "sub_...",
  "amount": 1900,
  "currency": "usd",
  "attempt_count": 2,
  "next_attempt_at": "2026-06-17T00:00:00+00:00",
  "failed_at": "2026-06-13T17:17:45+00:00"
}
invoice.upcomingUpcoming renewal (~7 days before)
{
  "user_id": "91664f86-...",
  "user_email": "[email protected]",
  "stripe_subscription_id": "sub_...",
  "amount_due": 1900,
  "currency": "usd",
  "renewal_at": "2026-07-13"
}

Retries

A delivery is successful only on an HTTP 2xx response within the 5-second timeout. Anything else — non-2xx, timeout, or network error — is retried with exponential backoff (1m, 2m, 4m, 8m … capped at 1h), up to 5 attempts total. Retries are dispatched by a job that runs every couple of minutes, so an attempt lands on the first tick after its backoff elapses rather than to the second. After that the delivery is marked failed and not retried again.

Each retry is re-signed with a fresh X-Onelo-Timestamp (so the signature changes), but the id / X-Onelo-Delivery stays the same. Deduplicate on it so a retried event isn't processed twice.

Best Practices

  • — Always verify X-Onelo-Signature (using X-Onelo-Timestamp) before processing any event.
  • — Respond with 200 OK immediately and do slow work asynchronously — Onelo has a 5-second timeout, after which the delivery is retried.
  • — Use the envelope id (or X-Onelo-Delivery) for idempotent processing — retries reuse the same id.
  • — Store your webhook secret in an environment variable, never in source code.
  • — You can add multiple webhook endpoints per app, each subscribing to a different set of events and each with its own secret.

Troubleshooting

SymptomLikely cause / fix
Can't add a webhookWebhooks require a Pro or Business plan.
URL rejected on saveThe endpoint must be https:// and resolve to a public address. http://, localhost and private/loopback IPs are blocked (SSRF protection).
Signature never matchesYou are almost certainly hashing a re-serialized body. Sign the raw bytes, and remember the signed string is {timestamp}.{rawBody} using the X-Onelo-Timestamp header — not the body alone.
Events stop arriving after one failureNon-2xx responses and timeouts are retried up to 5× with exponential backoff, then the delivery is marked failed. Return 2xx within 5 seconds.
Same event delivered twiceRetries reuse the same id / X-Onelo-Delivery. Deduplicate on it.
Lost the signing secretSecrets are shown once and stored encrypted — they can't be recovered. Delete the endpoint and create a new one to get a fresh secret.
Subscribed to a payment event but nothing arrivesPayment/subscription/invoice events only fire for activity on that app's Stripe paywall. Confirm a real (test-mode) transaction occurred.

Planned events

The events above are the ones currently emitted. The following are planned but not yet sent — they are intentionally not offered in the dashboard event picker, so don't build against them yet:

GroupPlanned events
Roadmaproadmap.item_created, roadmap.status_changed, roadmap.item_shipped
Webhooks — Onelo Docs