Receive data from other systems
Most write paths into a Homespun app authenticate as the agent or a signed-in human. An inbound catch-hook is the exception: it gives an external system a secret URL to POST JSON to, and that JSON is written into one of your app’s collections with no agent online. Use it for Stripe events, a Zapier or Make push, a Home Assistant automation, or an email router that turns a message into a webhook.
Declare a hook in the manifest
Section titled “Declare a hook in the manifest”Hooks are declared in x-homespun-manifest.ingest, validated at deploy exactly like webhooks,
and materialized into a secret URL. There is no dashboard-created or agent-created hook: you add
one by editing the manifest and redeploying.
"ingest": [ { "name": "stripe-payments", "collection": "payments", "mode": "upsert", "upsertOn": "external_id", "map": { "external_id": "id", "amount": "data.object.amount", "customer": "data.object.customer" }, "dedupeKey": "id", "wake": true }]Field by field:
name(required): unique within the app,[a-z0-9][a-z0-9_-]{0,63}. This names the hook and survives redeploys: the same name keeps the same URL, so a sender you configured weeks ago keeps working.collection(required): the declared collection the incoming row is written into.mode:append(default) writes a new row per delivery;upsertmerges onto an existing row.upsertOn: required whenmodeisupsert, and must name one of the collection’s unique fields. A redelivery with the same key updates the row in place.map(optional): target row field to a dot-path into the JSON body. Absent means the raw default row{ hook, payload, receivedAt }, with the whole body underpayload. A path that resolves to nothing omits that field, it never writes a null.dedupeKey(optional): a dot-path whose value dedupes a redelivery. A second delivery carrying a value already seen is acked200and recorded asdropped_duplicate, not written again. Set this for any sender that retries (Stripe redelivers).handshake: set toechoto answer a Slack or Microsoft Graph URL-verification challenge.verify(optional): opt into body-signature verification.{ "scheme": "github" }requires a valid GitHub HMAC-SHA256 signature over the raw body (see below). Strictly opt-in: without it the URL secret is the only check.wake:trueauto-wakes a dormant app on delivery (defaultfalse).
Nullable fields
Section titled “Nullable fields”A mapped field arrives one of two ways, and the collection schema has to allow both:
- The source path is absent from the body: the field is omitted from the row (it is never
written as
null). - The source path is present with an explicit
null: the field is written asnull.
So a sender that sends null for a field, rather than leaving it out, needs that field typed as
nullable in your collection schema, or the delivery fails validation.
GitHub is the classic case. A workflow_job webhook sends "conclusion": null,
"completed_at": null, and "runner_name": null on the queued and in_progress events, with the
real values arriving only on completed. A collection that types those as a plain "string"
accepts every completed delivery and rejects every queued / in_progress one with
row_schema_violation, even though the payloads look identical in shape. Type any field a sender
can send as null with a nullable JSON Schema type:
{ "conclusion": { "type": ["string", "null"] } }When a delivery does fail this way, the failed delivery’s detail names the field and the received
value, for example /conclusion must be string (received: null), so the cause is visible in the
delivery log without diffing a working payload against a failing one. Fix the schema, redeploy, and
replay the failed deliveries (see below).
Verify GitHub signatures
Section titled “Verify GitHub signatures”By default the URL secret is the whole authentication. For a GitHub webhook you can require a
signature over the body as well, so a leaked URL alone cannot post forged deliveries. Add verify
to the rule and provision a signing secret:
"ingest": [ { "name": "gh-issues", "collection": "issues", "verify": { "scheme": "github" } }]-
Deploy the manifest. The hook is fail-closed until you provision its signing secret: every delivery gets
401until you complete step 2. -
Mint the signing secret and copy the value it shows once:
Terminal window homespun ingest signing-secret set --app <idOrSlug> --name gh-issues -
In the GitHub webhook settings, paste that value into Secret, and set Content type to
application/json(aapplication/x-www-form-urlencodedwebhook gets415, since the endpoint accepts JSON only).
The relay reads GitHub’s X-Hub-Signature-256 header, recomputes the HMAC over the exact bytes
GitHub sent, and compares in constant time. A missing, malformed, or wrong signature (and a hook
whose signing secret is not set) all return one uniform 401, and the rejected request is not
recorded in the delivery log. Rotate the secret with homespun ingest signing-secret set again: the
previous value keeps verifying for a grace window so deliveries do not drop while you update GitHub.
The URL
Section titled “The URL”After deploy, read back the hook’s full secret URL:
homespun ingest list --app <idOrSlug>Each hook returns a url shaped https://<relay>/v1/ingest/<hookId>/<secret>. The secret in the
URL is the whole authentication: no header, no cookie. Hand that URL to the owner to paste into the
external system, and treat it like a password. If it leaks, rotate it (the old URL stops working
immediately, no redeploy needed):
homespun ingest rotate --app <idOrSlug> --name stripe-paymentsAn owner can also see the URL, a ready-made curl test, the rotate button, and the delivery log in
the app’s dashboard under Inbound hooks.
What a sender sees
Section titled “What a sender sees”The endpoint is fire-and-forget. Past a handful of guard checks it always acks 200 {"ok":true} and
records the outcome in the delivery log, so a sender never retries into a mapping bug forever. The
only failures a sender can see are 429 (rate or hourly cap), 404 (wrong URL, uniform so it is
not an existence oracle), 415 (not application/json), and 413 (body over the size cap).
Once accepted, the outcome is one of:
accepted: the row was written (and flows through feed replay, live delivery to a connected agent, notify rules, and outbound webhooks like any other write).dropped_duplicate: a redelivery of a seendedupeKeyvalue; no row written.failed: the write was rejected (a schema violation, a quota, an append-only conflict). The sender still got its200; the failure is yours to see in the log.
Inspect and replay
Section titled “Inspect and replay”List recent deliveries, filter by hook or status:
# via the HTTP API (agent-or-owner auth)GET /v1/apps/<id>/ingest/deliveries?hook=stripe-payments&status=failed&limit=25Each delivery keeps a capped snapshot of the received payload. That powers the debugging loop: when
a delivery failed because your map was wrong, fix the manifest, redeploy, and replay the
stored payload through the corrected rule:
POST /v1/apps/<id>/ingest/deliveries/<deliveryId>/replayReplay respects the current manifest: a hook renamed or removed since the original delivery gives a
clean error rather than writing into the wrong place. A replay bypasses dedupe (you asked for it
explicitly), so an append hook can produce a duplicate row on replay, while an upsert hook just
merges onto the same key again.
The delivery journal is pruned automatically (by age and a per-app cap), so it is a rolling window for debugging, not a permanent store: the collection rows are the durable record.