Lifecycle

Webhooks

Events are facts, recorded once. Deliveries are attempts to tell you about them. Retrying the second never re-runs the first — which is the property that stops a retry from paying a player twice.

Status

The sender is ready. Reconciliation is still authoritative.

Events are emitted and stored durably, deliveries are queued against registered endpoints with a working retry schedule, and the HTTP sender is now switched on. Both things that used to stand in front of it are closed.

Signing secrets are encrypted at rest under a versioned keyring. Each ciphertext names the key that produced it, older keys stay able to decrypt, and a rotation re-encrypts deliberately — so turning a key over does not strand the secrets already stored under the last one.

Egress is allow-listed, with every non-public address refused. A sender POSTs to a partner-supplied URL from inside our network; without a declared set of legal destinations that is server-side request forgery with a schedule attached. The allow-list is mandatory and a destination is verified when it is registered and again when it changes.

A deployment with no destinations declares that explicitly rather than being misconfigured, so “ready with nothing to deliver” and “broken” are different states you can tell apart.

Never make a webhook the only way you learn about money. Delivery is a convenience; /api/v1/reconciliation is the authoritative record and is designed to be polled. An integration that books a credit only on a webhook will eventually miss one.

Nothing is lost meanwhile. An undelivered event is a durable row that replays once the sender exists. A queue with no drain that looked finished would have been the dangerous version.

What to do today

Poll GET /api/v1/settlements. It carries the same fact as position.settled and keys on the same positionId, so a partner may use either without processing it twice — and can move from one to the other without changing how the credit is deduplicated. For everything else, the reconciliation feed is a totally ordered, resumable stream of every money fact.

Event types

TypeEmitted whenCorrelates on
order.filledA fill is recorded.orderId and your clientOrderId, with the side, action, quantity, average price and fee.
order.rejectedThe execution layer refuses an order.orderId, clientOrderId and the reason.
position.settledA position settles against its contract’s resolution.positionId and settlementId, with the resolution, outcome, contracts, cost basis, entry fees, the amount to credit and the realised P&L.
market.resolution_changedA resolution changes after positions had already settled against it.The affected positionId, the settled and current resolutions, and action: "operator_review_required".
position.updatedDefined in the event union.Not emitted by any code path today.

Delivered payloads will be narrowed to the partner form exactly as the REST surface is: your own identifiers, opaque ctr_… contract ids, and no source named anywhere.

market.resolution_changed carries no instruction

It reports that a source changed its mind about something you were already paid on. No money has moved and none will move automatically. One event is emitted per change, not per sweep, so a job running every five minutes does not raise the same alarm twelve times an hour.

The delivery envelope

POST to your endpoint
{
  "id": "<eventId>",
  "type": "position.settled",
  "dedupeKey": "position.settled:<positionId>",
  "createdAt": "2026-08-21T18:02:11.004Z",
  "data": { ... }
}
headers
content-type: application/json
predicta-signature: t=1755751331,v1=<hex hmac-sha256 of "<t>.<body>">
predicta-event-type: position.settled
user-agent: Predicta-Webhooks/1
  • dedupeKey is the natural key of the fact, not of the attempt to report it. Key your own processing on it. At-least-once is the only guarantee an HTTP retry can offer, and this is what makes that safe.
  • Answer 2xx quickly and process asynchronously. A delivery times out at 10 seconds.
  • Redirects are never followed, and a 3xx is treated as a delivery failure. A redirect is a destination nobody verified.

Verifying a signature

The timestamp is inside the signed material, not merely alongside it. A signature over the body alone is replayable forever: anyone who observes one valid delivery can resend it a year later and it still verifies. The tolerance is 300 seconds.

typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(secret: string, body: string, header: string, toleranceSeconds = 300) {
  const parts = new Map(header.split(',').map((p) => p.split('=') as [string, string]));
  const t = Number(parts.get('t'));
  const v1 = parts.get('v1');
  if (!Number.isFinite(t) || !v1) return false;

  // An old signature is refused even when the MAC is perfect. That is the whole
  // purpose of putting the timestamp in the signed material.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest();
  const given = Buffer.from(v1, 'hex');
  if (given.length !== expected.length) return false;
  return timingSafeEqual(expected, given);
}
  • Verify against the raw body, before any JSON parsing. Re-serialising changes the bytes and the MAC will not match.
  • Signing secrets are prefixed whsec_ and are encrypted at rest under a key held outside the database.
  • An unsigned delivery is never sent. If signing is unavailable the delivery is marked failed rather than sent in the clear.

Retries

AttemptDelay after the previous failure
1immediate
25 minutes
325 minutes
42 hours 5 minutes
510 hours
610 hours — the last. If it fails, the delivery is marked exhausted
  • Six attempts over 22 hours 35 minutes. These are the delays a delivery is measured walking, not the ones the schedule was described as producing — the two differed by a step until a test went and checked.
  • The tail is deliberately long. An endpoint that has been down for ten hours is having an incident, and hammering it every minute helps nobody.
  • exhausted is a distinct terminal state from failed: it means Predicta stopped trying, which is something an operator needs to be able to find and replay rather than silently lose.

Destination requirements

A partner-supplied URL is an attacker-influenced outbound request from inside Predicta's network, so the destination rules are strict and are re-checked on every attempt — not once at registration, because DNS can be repointed afterwards and a retry hours later is exactly when that would be exploited.

  • https only, on port 443. No credentials in the URL.
  • The hostname must be on an explicit allow-list, which fails closed.
  • Every resolved address must be public unicast. Loopback, private ranges, link-local (cloud instance metadata), carrier-grade NAT and multicast are all refused — and every address is checked, not just the first.

Known limitation, stated plainly

DNS rebinding is not closed. The hostname is resolved for the check and resolved again when the connection is made, and the answer can change in between. Closing it requires pinning the socket to the verified address, or an egress proxy that enforces the destination. Neither exists yet, which is why the allow-list is mandatory and why a destination you have not vetted should not be on it.