Skip to content

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.

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; upsert merges onto an existing row.
  • upsertOn: required when mode is upsert, 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 under payload. 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 acked 200 and recorded as dropped_duplicate, not written again. Set this for any sender that retries (Stripe redelivers).
  • handshake: set to echo to 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: true auto-wakes a dormant app on delivery (default false).

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 as null.

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).

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" }
}
]
  1. Deploy the manifest. The hook is fail-closed until you provision its signing secret: every delivery gets 401 until you complete step 2.

  2. Mint the signing secret and copy the value it shows once:

    Terminal window
    homespun ingest signing-secret set --app <idOrSlug> --name gh-issues
  3. In the GitHub webhook settings, paste that value into Secret, and set Content type to application/json (a application/x-www-form-urlencoded webhook gets 415, 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.

After deploy, read back the hook’s full secret URL:

Terminal window
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):

Terminal window
homespun ingest rotate --app <idOrSlug> --name stripe-payments

An 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.

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 seen dedupeKey value; no row written.
  • failed: the write was rejected (a schema violation, a quota, an append-only conflict). The sender still got its 200; the failure is yours to see in the log.

List recent deliveries, filter by hook or status:

Terminal window
# via the HTTP API (agent-or-owner auth)
GET /v1/apps/<id>/ingest/deliveries?hook=stripe-payments&status=failed&limit=25

Each 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:

Terminal window
POST /v1/apps/<id>/ingest/deliveries/<deliveryId>/replay

Replay 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.