# 0027 · Another app starts an automation by sending to an address CoDNA gives it

**Status:** Accepted · 2026-11-01. It adds a kind to the connections of
[0025](https://codna.me/docs/adr/0025-people-own-connections.md) and a third way for an automation to start, beside events
and a schedule ([automations.md](https://codna.me/docs/automations.md#triggers)). The sandbox
([0007](https://codna.me/docs/adr/0007-quickjs-sandbox.md)) does not change.

## Context

An automation starts when something happens in CoDNA, or on a schedule. Nothing outside can start
one. With connections, sources and a model ([0023](https://codna.me/docs/adr/0023-automations-reach-outside.md),
[0025](https://codna.me/docs/adr/0025-people-own-connections.md), [0026](https://codna.me/docs/adr/0026-scripts-ask-a-model.md)), CoDNA can read
another system, write a page and tell people. But it can only do that on a timer, never when the
other system has something to say:

- a form is filled in, and the answer should be filed and the team told;
- a payment arrives, a deal closes or a ticket is opened, and it belongs in the company's memory;
- GitHub publishes a release, and the changelog page should get a line;
- an n8n or Zapier flow has finished its plumbing, and its result should become a page.

These apps all tell other systems the same way: they POST JSON to an address they were given, and
retry when they get no 2xx. Most can't sign in with OAuth or hold a token in a header. The address
is the credential, sometimes with a signature made with a shared secret.

Today the only way in is the API with an agent account's OAuth token
([0021](https://codna.me/docs/adr/0021-agents-are-members.md)), which none of these apps can do. Without a way in, a team
keeps n8n or Zapier for this, and the result never reaches CoDNA's memory.

What CoDNA already has fits the need:

- **A connection has an owner,** a sealed secret, sharing for use, management's switch and view, and
  an audit record of every change (0025).
- **A run acts as its owner,** with every action vetted. Its record says what started it, what it
  read and what it did.
- **Data from outside is handed to a script, never written anywhere** unless the script writes it
  (0025's sources).

## Decision

### An incoming connection has an address

A new kind of connection, `incoming`, receives instead of sending.

- **Made on the Connections page** by anyone the switch allows (0025), and by management for the
  instance. CoDNA makes its address, such as `https://acme.codna.me/in/k3J9…` with 43 random
  characters. Whoever has the address can send to it, so the address is the secret. It is kept
  sealed, and found by its hash.
- **Only its owner sees the address,** on its page, to copy into the other app. It is never shown
  over MCP, to those it is shared with, or to management. Then an address isn't left in an AI
  conversation, as a secret isn't (0025).
- **Make a new address** stops the old one at once. That is what to do when one has leaked.
- **Shared like any connection.** Those it is shared with may have their automations listen to it.
  They never see its address.
- **Switched off or removed,** it answers nothing but 404.

### An automation listens to one

A third kind of automation, beside `event` and `schedule`: `incoming`, which names the connection
it listens to.

```text
create_automation  name: file-form-answers  from: signup-form
```

`from` is looked up the way a post's connection is (0025): the owner's own, else one shared with
them, else the instance's. It is resolved when the automation is registered, and kept by the
connection's id. Several automations may listen to one connection, and each runs on every request.
The owner's use of it is checked on every run, as for a post.

The script is given the request:

```js
export default function ({ event, actions }) {
  // event: { type: 'request.received', at, id, request }
  const { from, method, contentType, query, headers, body, text } = event.request
  // body: the JSON, or the form's fields; text: anything else, as it came
  actions.write({
    into: 'output:signups/' + event.at.slice(0, 10) + '-' + event.id,
    title: 'Sign-up: ' + body.company,
    content: '…',
  })
}
```

Headers come lowercased, without `authorization`, `cookie` or the signature's header. An `inputs`
export works as it does on any run, so a script can read a source about what came in.

### What the sender is told

- **202** once the request is kept and its runs are queued. The runs come after, so the sender never
  waits on a script or learns what it did.
- **200, and no run,** when a request with the same delivery id came while that one is kept (7
  days; proposed as 24 hours, and made the time a request is kept when it was built). The
  delivery id is the `webhook-id`, `x-github-delivery` or `idempotency-key` header, or the event's
  `id` on a connection that checks Stripe's signature. Apps retry, and a retried request should not
  file a second page.
- **404** for an address that doesn't exist or was replaced, or a connection switched off. All three
  look the same, so an address can't be probed.
- **401** when the connection checks signatures and this one doesn't match (below).
- **405** for anything but POST, **413** above 1 MB, and **429**, with `Retry-After`, above 60
  requests a minute for one connection.

### Checking who sent it

A connection may also check a signature, with a secret pasted on its page, as any secret is. It
names one of the schemes apps use:

- `github`: `X-Hub-Signature-256`;
- `stripe`: `Stripe-Signature`;
- `standard-webhooks`: the Standard Webhooks headers that Resend, Clerk, Svix and others send;
- `hmac-sha256`: a header the owner names, hex or base64, for the rest (Shopify, Typeform…).

Then a request whose signature doesn't match is refused. For schemes with a time in them, so is
one more than five minutes old. Without a check, the address alone is the credential, which is how
n8n's, Zapier's and Slack's own addresses work.

### What is kept, and how a script is written for it

- **Each request is kept for 7 days,** with what it started. The owner sees the latest on the
  connection's page: when, from which address, how it was answered, and which runs it started.
- **A request that no automation listens to is still kept.** The way to build one is to make the
  connection, put its address in the other app, and send that app's test event. Then the owner's
  agent reads what really arrived and writes the script for it.
- **`list_connections` gives the latest request** on an incoming connection the person owns, its
  body cut at 16 KB. That is for writing the script, and only the owner's agent gets it.
- **`run_automation` runs an incoming automation on the latest request,** or on one the caller
  gives. That works with a dry run and a preview too (0026).

### Management

- **The switch on who makes connections** (0025) covers incoming ones. Allowed hosts don't apply,
  since nothing is sent.
- **Management sees each one,** with its owner, who it is shared with, and how many requests it
  took and refused. It can switch one off or remove it. It never sees an address or a secret.
- **The audit records** each one made, given a new address, given a check, shared, switched off or
  removed.

## Consequences

- **An instance has public addresses that start runs.** Whoever has one can start the automations
  listening to it, as often as the limit allows, and give them any data. The run still does only
  what its script says, as its owner. A leaked address can make a script post noise or spend the
  model budget. The limit, the budget, signature checks and making a new address bound that.
- **What a sender wrote can reach the model,** when a script asks one about a request, and it may
  carry instructions. The answer only ever becomes a page (0026), and the page says which
  automation wrote it. A sender can make a page say something untrue. They can't make an automation
  do anything its script doesn't.
- **What senders send is kept for 7 days,** and may hold personal data: a form's answers, a buyer's
  email. Maintenance deletes it, and a run's record keeps the request only as its event, cut at
  64 KB, for 30 days, as it keeps a source's answer.
- **Every instance answers POSTs on `/in/`.** A request to an address that doesn't exist costs one
  lookup.
- **n8n stays useful** for flows of many steps: this is the way in, not a workflow engine. An n8n
  flow can now end by sending to CoDNA, and CoDNA can already post to n8n.

## Alternatives rejected

- **The API, with an agent account's token** (today): the apps that send these can't sign in with
  OAuth, and most can't set a header.
- **An address per automation,** with no connection: a signing secret would live on the automation,
  which an agent writes. Two automations couldn't listen to one form, and nothing would give
  management a view or keep sharing and leaving consistent. A connection already does all of that.
- **The script answers the sender,** as n8n's "Respond to Webhook" does: the sender would wait on
  the run and its reads, up to 30 seconds. An answer built from what the owner can read also lets
  whoever holds the address read it.
- **Each request becomes a page** that fires `document.created`: a page of JSON for every request,
  in search and in people's inboxes. The script decides what is worth a page.
- **GET, any method, and large bodies:** these apps POST JSON or a form. A GET handshake, such as
  Meta's, or a file upload is a different thing (see Still open).

## Settled when it was accepted

Tornike accepted it as proposed on 2026-11-01, settling:

1. The address is shown to its owner whenever they open the connection.
2. The limits: 1 MB a request, 60 a minute for each connection, requests kept for 7 days.
3. Signature checks come in the second step. Until then, the address is the credential.

## Still open

- **Handshakes that need an answer:** Slack's `url_verification` (and with it Slack's signature),
  Meta's GET challenge, Microsoft Graph's validation token.
- **Keeping a request as a raw page,** as evidence, the way an ingestion is. A script writes only in
  `output` today (0026).
- **Signing in with OAuth,** for apps that register their webhooks through an API.

## Building it

1. **Incoming connections:** the kind, the address and `/in/`, keeping requests, the event, the
   runs, the answers, delivery ids and the limits. Then the Connections page, `from` in
   `create_automation` and the automation form, the latest request in `list_connections`, a request
   in `run_automation`, management's view and the audit.
2. **Signature checks:** the four schemes, with the secret pasted on the connection's page. A
   refused request is kept with why, so its owner sees what was wrong.
3. **Docs:** [automations.md](https://codna.me/docs/automations.md), the `set-up-an-automation` skill, and the guide to
   receiving from another app.
