solidhooks
Webhook ingestion, routing and delivery as a verifiable system of record.
https://api.solidhooks.com/auth
1.0-draft
Ed25519
10/14/2026
health.checkCheck the health status of the application.
connections.listList all connections for the organization.
connections.createCreate a new connection between a source and destination.
connections.getGet a connection by ID.
connections.updateUpdate a connection.
connections.removeDelete a connection.
connectors.listList the organization’s private-delivery connectors, decorated with live status.
connectors.createProvision a connector’s shared private-delivery infrastructure and return a reveal-once bootstrap token (used by each replica to self-enroll). Gated by the plan’s maxConnectors entitlement.
connectors.optionsDiscovery
curl https://api.solidhooks.com/.well-known/agent-configurationRegister an Agent
curl -X POST https://api.solidhooks.comhttps://api.solidhooks.com/auth/agent/register \
-H "Content-Type: application/json" \
-d '{
"name": "my-agent",
"public_key": "<your-ed25519-public-key>",
"mode": "delegated"
}'Lightweight, unpaginated list of connectors (id, name) for the private-destination picker.
connectors.getGet a connector by ID, decorated with live status.
connectors.removeDelete a connector and tear down its Ziti infrastructure. Blocked while destinations reference it.
connectors.listReplicasList a connector’s enrolled HA replica identities, decorated with live status and last-seen.
connectors.regenerateEnrollmentRotate the connector’s reveal-once bootstrap token. Invalidates the previous token; already-enrolled replicas keep running.
connectors.bootstrapPublic replica self-enrollment. Presents the connector’s bootstrap token and receives a one-time enrollment token (OTT) to run `ziti edge enroll`. Rate-limited and gated by the plan’s maxReplicasPerConnector.
connectors.heartbeatPublic replica liveness assertion. The replica presents the per-replica heartbeat credential it received at bootstrap and the server records that it is alive. Called on an interval by the connector agent; the reaper reads the result. Rate-limited per replica.
connectors.toggleDisabledEnable or disable a connector. Disabled connectors reject new private destinations.
sources.listList all sources for the organization.
sources.createCreate a new source for the organization. For a known provider (github, linear, stripe, shopify, nuvion, paystack) the provider determines the verifier and its whole signature scheme: send `provider` and `verifierConfig.secret` and omit `verifierType` — it is inferred from the provider. The scheme fields (hash, encoding, header, prefix, timestampHeader, signedPayloadTemplate) cannot be overridden — a value that differs from the preset is rejected. Echoing a preset value back unchanged IS accepted — only a differing value is refused. `toleranceSeconds` is configurable only where a timestamp exists to bound it — stripe, and hmac presets carrying a timestamp header (currently nuvion); github, linear, shopify and paystack reject it. Use provider `generic` to control the scheme yourself, in which case `verifierType` is required.
sources.optionsLightweight, unpaginated list of sources (id, name) for building filter/dropdown options. Capped at 200 results, no total count.
sources.getGet a source by ID for the organization.
sources.updateUpdate a source by ID for the organization. The changed fields travel inside a `data` object. A true PATCH for everything outside verification: send only the fields you are changing — `{ "data": { "name": "renamed" } }` is a complete payload, and `{ "data": {} }` is a valid no-op. The one envelope rule: `verifierType` and `verifierConfig` travel together. Touching verification (including rotating a secret) means sending both; a `verifierConfig` on its own is rejected. Within the envelope, `verifierConfig` is a merge patch while `provider` and `verifierType` stay unchanged: omitted keys keep their stored values, `null` clears an optional setting, and a provider or verifier change starts from a fresh config. Changing `provider` re-derives the verifier config from the new provider preset, so a source moved onto a known provider picks up its scheme rather than keeping the previous one. Re-derivation applies only when the effective `verifierType` matches that preset's — moving to `nuvion` while explicitly using `basic_auth` injects nothing. Preset scheme fields cannot be overridden here either, though echoing a preset value back unchanged is accepted — so GET-then-PATCH round-trips, including the secret-less config a GET returns. Omitting a secret keeps the stored one ONLY while `provider` and `verifierType` are unchanged; changing either requires a new secret (or `verifierType: "noop"` to disable verification), since the stored credential belongs to the previous configuration.
sources.removeSoft-delete a source by ID for the organization.
sources.toggleDisabledEnable or disable a source by ID. Disabled sources reject all incoming webhooks.
sources.rotateSecretAccept a new verifier secret while continuing to accept the current one for an overlap window. Rotation is driven by the producer (Shopify, Stripe, GitHub generate the secret on their side), so without an overlap there is no ordering that avoids rejected requests: paste here first and traffic still signed with the old value fails, rotate there first and traffic signed with the new one fails. Works for every verifier type — it operates on the stored secret, not the wire format.
sources.cancelRotationAbandon a rotation: restore the previous secret as current and close the window. Refused with 409 `ROTATION_IN_USE` once the new secret has already verified traffic — reverting then would reject webhooks the producer is signing correctly. Pass `force` to revert anyway.
sources.rotationStatusWhether a verifier-secret rotation is in progress, when its window closes, and how many requests each stored secret has verified. The per-secret counts exist because we perform verification for inbound webhooks; the equivalent is not knowable for outbound signing. `lastSeenAt` distinguishes "never seen" from "not seen recently", and a null `count` with `countersAvailable: false` means the counter store was unreachable — which is NOT the same as zero.
sources.testSignatureVerify a sample header set and body against this source's live verifier secrets and report which one matched. During a rotation window both are tried, so this answers whether a producer has switched. Unlike the inbound 401 — which stays opaque so it cannot act as an oracle — this DOES return why verification failed, because the caller is the authenticated tenant reading their own diagnostics. It never records slot usage, so testing cannot disturb the counters `cancelRotation` reads.
sources.rotateMaskRotate the mask ID for a source, generating a new ingest URL.
destinations.listList all destinations for the organization.
destinations.createCreate a new destination for the organization.
destinations.optionsLightweight, unpaginated list of destinations (id, name, url, method) for building filter/dropdown options. Optionally filtered by a name or URL substring. Capped at 200 results, no total count.
destinations.getGet a destination by ID for the organization.
destinations.updateUpdate a destination by ID for the organization.
destinations.removeDelete a destination by ID. Fails if connections still reference it.
destinations.toggleDisabledEnable or disable a destination by ID. Disabled destinations stop receiving deliveries.
destinations.revealSigningSecretDecrypt and return the HMAC signing secret for a destination.
destinations.rotateSigningSecretGenerate a new HMAC signing secret for a destination. Pass `overlapDays` to keep the displaced secret valid for a migration window, so deliveries carry both signatures and a receiver can adopt the new one on its own schedule. Without it the old secret stops working immediately. Requires the `standard_webhooks` scheme — the legacy signature header carries one value and cannot express an overlap. `force` rotates again while a window is still live, stranding receivers who have not adopted the current secret.
destinations.switchSignatureSchemeChange how deliveries to this destination are signed, and mint a fresh signing secret. BREAKING: there is no overlap window — an overlap signs with two secrets under one scheme and cannot span two, because the displaced secret is keyed differently under each and a receiver holding it cannot verify the new signature. Your endpoint must accept the new scheme BEFORE you call this. Retrieve the new secret with `destinations.revealSigningSecret`. Switching to the scheme already in force is a no-op. `standard_webhooks` emits `webhook-id` / `webhook-timestamp` / `webhook-signature` per the Standard Webhooks specification; `solidhooks_v1` is the legacy scheme, kept indefinitely.
destinations.sendTestDeliveryPost a signed sample payload to the destination and report the response. Shaped exactly like a real delivery (same method, signing and timeout) but carries `x-solidhooks-test: true`, records no event or delivery, and is never billed. A transport failure is reported in the result rather than raised as an error.
destinations.deliveryHealthDelivery outcomes for this destination since `since`, defaulting to the last 24 hours. Counts are anchored on delivery CREATION, so a delivery created before the window is excluded even if it was retried inside it. `pending` covers deliveries still in flight (queued or throttled), which are not yet an outcome either way.
destinations.endRotationOverlapRetire the previous signing secret immediately, closing a live overlap window early. This does NOT revert the rotation — deliveries have been signed with the current secret since it was minted; the previous one was only being honoured for the receiver's benefit. Idempotent: closing an already-closed window is a no-op.
events.listList all events for the organization.
events.hasAnyReturns whether the active organization/project has ever received any events. Used to distinguish a true new-user empty state from an empty filter window.
events.streamStreams events for the active organization/project as Server-Sent Events. The stream is forward-only: it starts at the moment of connection and emits events as they land; use the list endpoint for history. Pass `sourceId` to scope the stream to one source, or `after` to start from an earlier point (clamped to the last 24 hours). Resumption is standard SSE: every event carries an `id`, and reconnecting with a `Last-Event-ID` header picks the tail up exactly where it stopped, tie-breaks included. The stream closes after 30 minutes; reconnect rather than treating that as an error.
events.getGet an event by ID for the organization, including its deliveries.
events.timelineAn event's full journey, assembled: the ingest phases, then one branch per delivery carrying that delivery's own per-attempt history. This is the endpoint to reach for when a webhook went missing — the delivery row alone only holds the LATEST attempt's response, so it cannot answer what happened on attempts 1..n-1. **The fan-out is a tree, not a line:** one event produces one delivery per matched connection, and each has an independent attempt sequence. Two things to read carefully. The three ingest phases share ONE timestamp (`ingest.at`) because we record no per-phase timings — they are a derived state projection, not measurements, so do not read latency into them. The verification phase reports the verifier that actually ran, snapshotted at ingest on `events.verified_with`, so editing a source never relabels history. It is carried structurally as `verifier` on that phase and is set on a FAILURE too, so a rejection says which verifier rejected it — `status` is what reports the outcome. Events ingested before that column existed report the phase as `unknown` rather than guessing. Deliveries created before per-attempt history existed carry a single entry flagged `reconstructed`, synthesized from the delivery row.
events.replayRe-deliver an event to all currently active connections for its source. Each call is recorded with the principal that made it, and every delivery it creates points back at that record — read it as `replay` on the delivery branch of `events.timeline`, or `replayId` on the delivery itself. Replays are billed: they create new deliveries.
deliveries.listList deliveries with optional filters for status, source, destination, connection, event, and date range.
deliveries.streamStreams delivery activity for the active organization/project as Server-Sent Events. The stream is forward-only: it starts at the moment of connection and emits activity as it lands; use the list endpoint for history. A delivery is emitted when it is created and again on every attempt, so the same id recurs as it progresses — replace by id rather than appending. A manually retried delivery re-enters the stream when its next attempt starts, not at the moment it is re-queued. Pass `eventId` or `status` to scope the stream, or `after` to start from an earlier point (matched against activity time, clamped to the last 24 hours). Resumption is standard SSE: every event carries an `id`, and reconnecting with a `Last-Event-ID` header picks the tail up exactly where it stopped. The stream closes after 30 minutes; reconnect rather than treating that as an error.
deliveries.hasAnyReturns whether the active organization/project has ever recorded any deliveries. Used to distinguish a true new-user empty state from an empty filter window.
deliveries.statsLive snapshot of deliveries currently queued for the active organization/project, split into pending (awaiting their next attempt) and throttled (held by a rate limit). Use it to drive dashboard or agent polling for queue backlog and rate-limit pressure. Historical success/failure counts are windowed and live in the dashboard stats, not here.
deliveries.attemptsThe durable, per-attempt history of one delivery, oldest first. The delivery row itself only carries the LATEST attempt's response — every attempt overwrites the one before it — so this is the only record of what happened on earlier attempts, which is what you need when a webhook went missing. A `skipped` or `filtered` attempt is a TERMINAL non-send — a disabled route, or an event type no longer in the connection's allowlist — and the delivery row now carries the same value rather than `success`. Both stay terminal because an intentional skip must not be retried; neither is a failure. Order is by `startedAt` and NOT by `attemptNumber`, which is not unique: a throttled attempt consumes no retry budget and shares its number with the attempt that follows it. `nextRetryAt` on the final row separates a delivery still working through its retry schedule from one that has given up. Deliveries created before per-attempt history was recorded return no rows; use `events.timeline`, which reconstructs a single entry for them from the delivery row.
deliveries.retryRetry a failed delivery by re-queuing it.
projects.listList all projects for the active organization.
projects.createCreate a new project in the active organization.
projects.getGet a project by ID.
projects.updateUpdate a project by ID.
projects.removeDelete a project and all its resources (sources, destinations, events, deliveries).
stats.dashboardGet aggregated dashboard statistics for the project.
billing.planGet the active billing plan and effective entitlements (after per-organization overrides) for the organization.
billing.usageCurrent-window delivery usage against the plan quota. A delivery is one event routed to one connection; the window is the subscription billing period for subscribed organizations, else the UTC calendar month.
billing.cancelSchedule the organization subscription to cancel at the end of the current billing period. The plan, quota, and usage window stay unchanged until then; afterwards the organization falls back to the free tier. Owner/admin only. Undo with billing.resume before the period ends.
billing.resumeUndo a scheduled cancellation before the billing period ends. Owner/admin only.
billing.changePlanChange an existing organization subscription between paid tiers. Upgrades apply immediately and invoice the prorated difference now; downgrades (scheduleAtPeriodEnd=true) take effect at the end of the current billing period. Only for organizations that already have a subscription — the FIRST subscription is a Stripe Checkout browser step done from the dashboard (owner/admin), not via this endpoint. Moving to the free tier is a cancellation (use billing.cancel); enterprise plans are sales-led. Owner/admin only. Call billing.refresh afterwards, or read the returned state, to pick up the new entitlements.
billing.previewPlanChangePrice an immediate change to another paid tier WITHOUT making it, using Stripe's invoice preview — the amount due now, the proration lines behind it, and the recurring price that follows. Read-only: unlike billing.changePlan it does not release a pending scheduled change. Only prices the immediate direction; a downgrade scheduled to the period end charges nothing today. When Stripe cannot price the change the response carries available=false rather than an error, so a preview outage never blocks the upgrade itself. It also REFUSES to price — same available=false — while a scheduled change is pending: billing.changePlan releases that schedule before charging, so any figure Stripe returns for the scheduled state is one we would not honour. Revert the queued change (billing.revertScheduledChange) to get a quote. Same refusals as billing.changePlan otherwise. Owner/admin only.
billing.revertScheduledChangeRevert a plan change (typically a downgrade) that was scheduled to take effect at the end of the current billing period, releasing its Stripe subscription schedule so the organization keeps its current plan. No-op error if nothing is scheduled. Owner/admin only.
billing.refreshInvalidate the cached plan resolution for the organization and return the freshly resolved plan. Call after changing the subscription so entitlements update without waiting for the cache TTL or webhook.
billing.subscriptionGet the organization’s entitled Stripe subscription (if any) and whether a Stripe customer exists yet.
billing.plansList all active billing plans with pricing and entitlements, ordered for display.
countries.listList countries available for sign-up, ordered for display. Public: the sign-up form needs this before a session exists. Each row carries the billing currency and default locale an organization created from that country inherits. Countries we cannot serve are omitted, and submitting one is rejected server-side.
providers.listThe catalog of webhook providers a source can be created against, ordered for display. Public: it is reference data with no tenant content — the install count is a global aggregate across all organizations. Each entry carries the `family` that decides how the source is set up: `preset` providers have their signature scheme fixed server-side and need only a secret, `generic` hands you the whole verifier, and `email` has nothing to sign and takes an inbound address instead of an ingest URL.
providers.categoriesThe distinct categories present in the provider catalog, alphabetically. Backs the category filter on the provider picker so it offers only values that match something.
account.get_membershipsList the organizations this agent may act in, each with its projects. Call this first: an agent holds a delegated identity but no tenant, so it needs to know what it may work on before it can name a project.
account.select_projectChoose which project this agent works in. Remembered on the agent itself, so it applies to every later call until changed. Pick one from account.get_memberships and confirm with the human first.