§ Reference · API + Webhooks

Build on a billing recon backend.

Tenant-scoped REST API with bearer auth, signed outbound webhooks, native Slack/Teams integration. The same surface our app uses — published so you can wire us into your stack without a sales call.

Base URLhttps://velora-billing.vercel.app/api

Authentication

Every request requires a tenant-scoped API key. Generate one at /app/settings/api-keys after signing in. Keys are tenant-bound, write-revocable, and rate-limited per-key.

Include the key on every call via either header:

X-Api-Key: vb_live_a1b2c3d4...
# or
Authorization: Bearer vb_live_a1b2c3d4...

Keys are shown ONCE on creation. Store them in your secret manager and never commit them. Compromised keys can be revoked from the same settings page; new requests with the revoked key 401 within seconds.

Rate limits

Per-key sliding-window limits, enforced at the edge. Defaults:

BucketLimitWindow
api:read120 req60s
api:write30 req60s
auth:mfa_challenge10 req60s

Over-limit responses return 429 with these headers:

Retry-After: 12
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 2026-04-29T13:00:00Z

Need higher limits? Email support@hellovelora.com with your traffic profile — we'll raise individual buckets per tenant rather than charge for an arbitrary “Pro” tier.

Error format

All errors return JSON with a single error field. Validation failures additionally include a issues array (Zod-shaped — same as the request body).

{
  "error": "Invalid body",
  "issues": [
    { "code": "invalid_type", "path": ["amountClaimed"], "message": "Expected number" }
  ]
}
StatusMeaning
400Validation failure or malformed body
401Missing / invalid / revoked API key
403Auth OK but role insufficient (e.g. policy edit by MEMBER)
404Resource not found in your tenant scope
409Idempotency conflict, or stateful precondition (e.g. retry an already-OK delivery)
429Rate limited — retry after the header
500Bug on our side — open a support ticket with the X-Request-Id

Pagination

List endpoints use cursor pagination. Pass ?cursor=<id>&limit=50; the response includes nextCursor when more rows exist (null at end). Default limit varies per endpoint, max is 200.

GET /api/webhooks/wh_abc/deliveries?limit=50&cursor=del_xyz

{
  "deliveries": [...],
  "nextCursor": "del_qrs"
}

Endpoints

The integration-friendly subset. The full app surface is larger but these are stable and documented.

GET /api/integrations/recon-summary

Cross-product summary for embedding a Velora tile in your dashboard. Returns counts of unresolved discrepancies, urgent (HIGH/CRITICAL) findings, open disputes, and the timestamps of the latest invoice + recon run.

{
  "pendingTickets": 7,
  "urgentTickets": 2,
  "openDisputes": 4,
  "lastInvoiceAt": "2026-04-28T14:00:00Z",
  "lastReconAt": "2026-04-28T14:01:23Z",
  "asOf": "2026-04-29T20:00:00Z"
}

POST /api/recon

Trigger a reconciliation run on a previously-uploaded invoice. Idempotent on (invoiceId, runKey) — re-posting the same key returns the original run id.

GET /api/audits/[runId]/csv

Discrepancies for a run as RFC 4180 CSV. Auth-logged. ?format=qbo|netsuite on the GL endpoint variant returns balanced journal entries directly importable into QuickBooks Online or NetSuite.

GET /api/webhooks/[id]/deliveries

The append-only delivery log. Filter ?status=ok|failed, paginate with cursor. Each row includes status, statusCode, duration, errorMessage, and a retryable boolean — true when we still have the original payload retained for re-fire.

POST /api/webhooks/deliveries/[id]/retry

Re-fire a previous delivery using the EXACT original payload + signature. The new attempt creates a new delivery row pointing at the original via retryOfId; the original is never mutated. Receivers depending on idempotency keys correctly treat it as a duplicate.

Webhooks

Outbound, signed, retried with exponential backoff (0s, 5s, 30s, 5m, 30m). Subscribe at /app/settings/webhooks; pick the events you want. Each subscription has its own signing secret — rotate by deleting and re-creating.

Every delivery sends:

POST https://your-receiver.example.com/velora
Content-Type: application/json
X-Webhook-Event: dispute.sla_breached
X-Webhook-Delivery: del_abc123
X-Webhook-Timestamp: 1714400000
X-Webhook-Signature: sha256=...
User-Agent: Velora-Billing-Webhook/1.0

Event catalog

Fourteen events fire today. Subscriptions can include any subset.

EventWhen it firesKey fields in data
dispute.createdOperator opens or auto-recon opens a disputedisputeId, invoiceId, carrierName, amountClaimed
dispute.sentDispute email sent to carrier (after the 2-min undo window)disputeId, sentAt, recipient
dispute.repliedCarrier replies inbound — fires once per new message persisteddisputeId, threadId, emailMessageId, messageId, fromAddr, subject, receivedAt
dispute.resolvedDispute closes (RECOVERED, DENIED, or ABANDONED)disputeId, status, amountRecovered, bulk?
dispute.sla_breachedDaily cron flags a dispute past slaDueAtdisputeId, breachedAt, status, slaDueAt, amountClaimed
invoice.parsedA carrier invoice parses successfully and a row landsinvoiceId, carrierName, invoiceNumber, coveragePeriodStart, coveragePeriodEnd, totalAmount, parseConfidence, lineCount
invoice.parse_failedInvoice parser throws — receiver can mirror to its own pagingfilename, sizeBytes, reason
reconciliation.completedA recon run finishes (status=COMPLETED)runId, invoiceId, carrierName, discrepancyCount, dollarsAtRisk, estimatedRecovery
enrollment.snapshot.uploadedA new census uploads + parses successfullysnapshotId, asOfDate, memberCount
payroll.snapshot.uploadedA payroll deduction CSV uploads + parses successfully (Sprint B-3)snapshotId, employeeCount, deductionCount, totalDeducted, periodStart, periodEnd
billing_event.postedAny append to the BillingEvent ledger (premium, payment, retro, reversal, period close)eventId, kind, amountCents, priorBalanceCents, postBalanceCents, invoiceId, disputeId, memberExternalId, occurredAt, effectiveAt, reason
audit.chain_verifiedDaily 03:30 UTC sweep confirms the audit chain held overnightwalked, verifiedAt
audit.chain_break_detectedThe verifier finds a chain anomaly — page on thiswalked, breakAt, reason, detectedAt
ledger.chain_verifiedDaily 03:45 UTC sweep confirms the BillingEvent money ledger held overnightwalked, verifiedAt
ledger.chain_break_detectedThe verifier finds a money-log invariant break — page on thiswalked, breakAt, breakRowId, reason, details, detectedAt

Need an event we don't emit yet? Open an issue on GitHub. Adding a new event is two lines of dispatch + a doc update.

Verifying signatures

Compute HMAC-SHA256 of the raw request body using the subscription's signing secret, prefix with sha256=, and compare to X-Webhook-Signature. Use a constant-time compare to avoid timing oracles.

// Node 20+
import { createHmac, timingSafeEqual } from "crypto";

function verify(body: string, header: string, secret: string): boolean {
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(body, "utf8").digest("hex");
  if (header.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

// In your handler:
const body = await req.text();   // RAW body, not the parsed object
if (!verify(body, req.headers.get("x-webhook-signature") ?? "",
            process.env.VELORA_WEBHOOK_SECRET!)) {
  return new Response("invalid signature", { status: 401 });
}

The X-Webhook-Timestamp header is the dispatch time in epoch seconds. Reject requests older than 5 minutes to prevent replay.

Slack & Microsoft Teams

Paste a Slack incoming-webhook URL (hooks.slack.com/services/...) or Teams connector URL (*.webhook.office.com/...) into the standard webhook form. We detect the channel and translate each event into the channel's native rich-card schema:

  • Slack: Block Kit message — header + section with up to 4 fact pairs + an action button linking back into the app.
  • Teams: MessageCard schema with brand themeColor, fact table, and OpenUri action.

Chat-channel deliveries are unsigned — Slack and Teams treat URL secrecy as the auth credential and don't define a verify scheme on incoming webhooks. Delivery rows still capture status + statusCode + duration the same way as signed deliveries.

Manual retry

The deliveries log retains the original payload (up to 16 KiB). Failed deliveries can be re-fired manually from the UI or via API. The retry uses the EXACT original payload + signature, so receivers depending on idempotency keys treat it correctly as a duplicate.

POST /api/webhooks/deliveries/del_abc123/retry

# Response
{
  "deliveryId": "del_xyz789",
  "status": "ok",
  "statusCode": 200
}

Old deliveries (pre-2026-04-28) and over-cap payloads return 409 with "error": "Original payload was not retained".

Found something missing or wrong? Tell us.

Get an API key — free