# 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”](#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; `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`).

## Nullable fields

[Section titled “Nullable fields”](#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 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).

## Verify GitHub signatures

[Section titled “Verify GitHub signatures”](#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" }
  }
]
```

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.

## The URL

[Section titled “The URL”](#the-url)

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

## What a sender sees

[Section titled “What a sender sees”](#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 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.

## Inspect and replay

[Section titled “Inspect and replay”](#inspect-and-replay)

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.
