Webhooks
Sloppy ships a first-party webhook subsystem for both outbound delivery and inbound verification. Outbound delivery uses a provider-backed outbox: a publish inside your business transaction writes an outbox row, and a separate worker signs and POSTs each enabled subscription. Delivery is at least once — receivers must be idempotent.
This page is the user-facing guide. The full API surface is documented at Core APIs / Webhooks, and the reference table at Reference / Webhooks.
Define an event
Events are stable, versioned descriptors with a Sloppy schema. The schema is validated before any outbox row is written.
import { Webhooks, schema } from "sloppy";
export const OrderCreated = Webhooks.event("order.created", {
version: 1,
schema: schema.object({
orderId: schema.string(),
customerId: schema.string(),
total: schema.number(),
}),
});Names are dotted identifiers (order.created, invoice.paid). Versions are positive integers; bump the version when the schema changes in a breaking way.
Register the outbox
The outbox needs a data provider for storage, an HTTP client for delivery, and a signing secret. Register it through services.addWebhooks(...):
import { Config, Http, Sloppy, Webhooks } from "sloppy";
const builder = Sloppy.createBuilder();
builder.services.addHttpClient(Http.client("webhooks", {
baseUrl: Config.required("Webhooks:BaseUrl"),
retry: Http.retry.none(),
}));
builder.services.addWebhooks(Webhooks.outbox({
provider: "main",
signingSecret: Config.requiredSecret("Webhooks:SigningSecret"),
delivery: {
client: "webhooks",
retry: Webhooks.retry.exponential({
maxAttempts: 8,
initialDelayMs: 1000,
maxDelayMs: 300000,
}),
},
}));
const app = builder.build();The outbox provisions four provider-backed tables: sloppy_webhook_subscriptions, sloppy_webhook_outbox, sloppy_webhook_delivery_attempts, and sloppy_webhook_inbound_dedup. Use Webhooks.sql(provider) to read the SQL templates if you need them for custom migrations.
Configure the delivery HTTP client with Http.retry.none() — retry is owned by the webhook subsystem, not by hidden HTTP-client retry.
Publish inside a transaction
ctx.webhooks.publish(tx, event, payload) writes the outbox row inside the same database transaction as the business change, so a successful commit guarantees the event is durable. Validation runs first; invalid payloads throw SLOPPY_E_WEBHOOK_EVENT_VALIDATION_FAILED and never write a row.
app.post("/orders", async (ctx) => {
const order = await ctx.services.get("data.main").transaction(async (tx) => {
const created = await createOrder(tx, ctx.body);
await ctx.webhooks.publish(tx, OrderCreated, {
orderId: created.id,
customerId: created.customerId,
total: created.total,
});
return created;
});
return Results.created(`/orders/${order.id}`, order);
});For idempotent producers, pass idempotencyKey:
await ctx.webhooks.publish(tx, OrderCreated, payload, {
idempotencyKey: `order:${order.id}:created`,
});The provider schema enforces one outbox row per key — repeated publish calls return the existing event metadata.
Manage subscriptions
Subscriptions are managed through ctx.webhooks.subscriptions (or the resolved service):
await webhooks.subscriptions.create({
event: "order.created",
url: "https://partner.example.com/webhooks/orders",
secret: Config.requiredSecret("Customer:WebhookSecret"),
headers: { "X-Customer": "acme" },
});Supported operations: create, get, list, update, enable, disable, delete. Subscription secrets sign deliveries for that subscription when present; the outbox signingSecret is the fallback. Secrets are never returned from get or list, and partial updates preserve the existing secret unless secret is supplied again.
User-configured endpoints reject loopback, localhost, link-local, and private network destinations by default. Set allowPrivateNetworks: true only for trusted local receivers in tests or private deployments.
Deliver pending events
Delivery runs through webhooks.deliverPending() or the job descriptor. Wire it through a background service, a scheduled trigger you own, or a worker queue depending on how you operate the app:
import { Webhooks } from "sloppy";
await Webhooks.jobs.deliverPending({ batchSize: 100 }).run({ webhooks });Each call claims a batch of pending rows with a lease, sends one signed POST per enabled subscription, records a delivery-attempt row, and updates the outbox status. Statuses are pending, delivering, delivered, failed, and dead_letter.
Retry behavior:
- HTTP
2xx→ success. - Default retryable statuses:
408,425,429,500,502,503,504. SettingretryOnStatusreplaces that list, not appends. 400,401,403,404, and other non-retryable responses move the row todead_letterafter a single attempt is recorded.- Network errors are retryable while attempts remain.
Retry-Afteris honored for retryable responses.
Outbound signatures
Sloppy deliveries set the following headers:
Sloppy-Webhook-IdSloppy-Webhook-EventSloppy-Webhook-TimestampSloppy-Webhook-SignatureSloppy-Webhook-Attempt
Signature format is v1=<hex hmac sha256> computed over timestamp + "." + body using the exact JSON bytes sent to the receiver.
Verify inbound webhooks
Webhooks.verify(ctxOrRequest, options) validates HMAC-SHA256, the timestamp tolerance, the exact body bytes, and (optionally) replay deduplication. It returns the delivery id, event name, timestamp, parsed payload, raw body, and headers:
app.post("/integrations/sloppy", async (ctx) => {
const event = await Webhooks.verify(ctx, {
secret: Config.requiredSecret("Inbound:WebhookSecret"),
toleranceMs: 300_000,
});
return Results.ok({ received: event.id });
});Pass secrets: [current, previous] to support secret rotation.
Test webhooks
Run the bootstrap webhook tests for executable coverage:
node tests/bootstrap/test_webhooks.mjsFor app-level integration tests, the public surface is shaped to be testable with TestHost and TestServices — see TestHost and TestServices. Use Webhooks.sign(payload, options) to generate signature headers when sending fixture requests through TestHost.
Errors
Webhook failures throw SloppyWebhookError with stable codes: SLOPPY_E_WEBHOOK_INVALID_OPTIONS, SLOPPY_E_WEBHOOK_EVENT_VALIDATION_FAILED, SLOPPY_E_WEBHOOK_OUTBOX_UNAVAILABLE, SLOPPY_E_WEBHOOK_DELIVERY_FAILED, SLOPPY_E_WEBHOOK_SIGNATURE_INVALID, SLOPPY_E_WEBHOOK_REPLAY_DETECTED, SLOPPY_E_WEBHOOK_TIMESTAMP_OUT_OF_RANGE, SLOPPY_E_WEBHOOK_SECRET_UNAVAILABLE, and SLOPPY_E_WEBHOOK_CLOSED.
Examples
examples/webhooks-basic— event descriptor, outbox registration, transactional publish, and signed delivery wiring
Bootstrap tests under tests/bootstrap/test_webhooks.mjs cover the end-to-end shape. Current support boundaries are tracked in the stability matrix.