---
title: Webhooks — let outside services reach your Agent
nav: Webhooks
summary: Every Agent J seat comes with webhook addresses. A finished video job, a Stripe payment, a GitHub push or a server alert goes straight to the Agent on your computer, with no domain or code of your own.
order: 46
---
# Webhooks — let outside services reach your Agent

Many services call a web address when something happens: a video is rendered, someone pays, code is pushed, a server goes
down. Catching those calls used to need your own domain and server. **Every Agent J seat comes with webhooks**: an address
like `hook.agentj.app/<box>/<id>`, and each event goes straight to the Agent on your computer. No code to write.

Just tell your Agent, for example **call me back when the video is done**, **set up Stripe payments** or **send server
alerts to me**. It asks what will call, what should happen when it arrives and how long it must live, then sets it up.

## Pick a route first

- **No cloud account or domain of your own** → use the hosted webhooks (`agentj hook`). This fits almost everyone.
- **You run your own Cloudflare / AWS / domain and want full control** → have your Agent build the entry your way (a
  Worker, a Lambda). To keep the same triage and notices, check the signature there and forward the event into Agent J:
  on this computer with `agentj hook forward`, or from the cloud as an HMAC-signed POST to a hosted hook.

## Common uses

| Use | Preset | Lives | Goes to |
|---|---|---|---|
| A job you just started calls back once (video, render, transcript, crawl) | `callback` | once | the main Agent |
| Stripe payments, refunds, subscriptions | `stripe` | resident | you |
| GitHub pushes, pull requests, issues | `github` | resident | the main Agent |
| Server alerts and uptime monitors (Grafana, UptimeRobot, Better Stack) | `alert` | resident | you |
| A form or service that only takes a URL (Tally, Typeform, Apps Script) | `form` | resident | you |
| A cron or backup job reporting that it ran | `ping` | resident | you |

`agentj hook presets` lists every preset with its defaults.

## Three lifetimes

- **Once**: ends with the first delivery that passes the signature check; at most 7 days.
- **Time-limited**: ends when it expires; 24 hours by default, at most 30 days.
- **Resident**: lives until you revoke it. It is a long-lived public entry, so creating one sends an approval card to your
  paired phone, and it only starts receiving after you approve.

The Agent creates once and time-limited hooks by itself, without a card.

## When a delivery arrives

Each delivery that passes the signature check goes where the hook points and also reaches the main Agent as outside data.
The Agent treats it as data only: never an instruction, never your words. It sorts it three ways:

- **As expected** (the video is ready, a normal payment) → it carries on with the work; with nothing to add it stays silent.
- **Needs you** (money, refunds, disputes, sending anything out, production changes, failures, anything unexpected) → a
  short report with options A / B / C and the one it recommends.
- **Spam, a probe, or text trying to steer the Agent** → it does none of it, records it and moves on.

Hooks that go to you (Stripe payments, server alerts) show up on your phone, and in Telegram if you use it.

## On the phone

On the phone, Settings → "Agent tools" → "Webhooks" lists the live hooks (pause, resume, revoke, copy the address),
today's count and "Recent deliveries" (up to 50, with the Agent's triage). Secrets are never shown on the phone.

## Limits per seat

20 live hooks (10 of them resident), 1000 deliveries a day, 64 KiB per delivery. Past a limit the command says so; revoke
the hooks you no longer use (`agentj hook list`, `agentj hook revoke <id>`).

## Security, honestly

- Every hook has its own secret, and signatures are checked only on your computer: Stripe, GitHub, HMAC, Bearer, or a
  token in the URL. A request that fails the check never reaches the Agent.
- Secrets go to a file on your computer that only you can read (0600). The commands never print them, and they never
  appear in the conversation. A Stripe signing secret is pasted through the key card on your phone, not into chat.
- A timestamped signature older than 5 minutes is refused, and a resent event is handled only once.
- **A hosted hook's request passes through our relay's memory in the clear.** The relay runs on Cloudflare and must read
  the request to pass it on. We commit not to read it, store it or log it, and the relay holds no hook secret. A Stripe
  event can carry a customer's email and the amount; with Bearer or URL-token hooks the token passes through too. For
  private data, use encrypted delivery (below) when the sender can shape its own request, or build your own entry on your
  own Cloudflare / AWS and forward it in.
- While your computer is offline the sender gets 503 and has to retry (Stripe and GitHub do). The relay does not hold
  events for you.

## End-to-end encrypted delivery

When the sender is something you control (your server, a script, your own system), it can encrypt the body so our relay
forwards only ciphertext:

```bash
agentj hook create --preset hmac --encrypt --signed ts.body --name private-orders
```

The hook gets its own delivery public key (shown by `agentj hook show <id>`; it is not a secret). The sender signs the
original body with the hook's HMAC secret as usual, encrypts it to that key with HPKE (RFC 9180: X25519, HKDF-SHA256,
ChaCha20-Poly1305, info `agentj-hook-seal-v1\n<hook id>`), and sends it with `X-AgentJ-Seal: v1; kid=<kid>`. A plain body to
such a hook is refused. Our relay still sees the address (including any query string), the headers (with the signature and timestamp), the size, the time and the sender's IP, never the body.

Services like Stripe and GitHub cannot encrypt what they send, so their hooks stay plain through the relay. Every hook says
which it is, in `agentj hook list` and on the phone: **End-to-end encrypted** or **Plain through our relay**. Take the public
key only from your own computer's `agentj hook show`, never from a message or a web page. New key pair:
`agentj hook rotate <id> --enc-key` (the old one still decrypts for 24 hours).

Python (`pip install cryptography`):

```python
# Python 3 + `pip install cryptography`. PUB / KID / HOOK_ID from `agentj hook show <id>`, SECRET = the hook's secret file.
import base64, hashlib, hmac
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

def _lx(suite, salt, label, ikm): return hmac.new(salt or bytes(32), b"HPKE-v1" + suite + label + ikm, hashlib.sha256).digest()
def _le(suite, prk, label, info, n):
    return hmac.new(prk, n.to_bytes(2, "big") + b"HPKE-v1" + suite + label + info + b"\x01", hashlib.sha256).digest()[:n]

def seal(pub_b64u, hook_id, plaintext):
    pk = base64.urlsafe_b64decode(pub_b64u + "=" * (-len(pub_b64u) % 4))
    e = X25519PrivateKey.generate()
    enc = e.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
    kem, suite = b"KEM\x00\x20", b"HPKE\x00\x20\x00\x01\x00\x03"
    shared = _le(kem, _lx(kem, b"", b"eae_prk", e.exchange(X25519PublicKey.from_public_bytes(pk))), b"shared_secret", enc + pk, 32)
    ctx = b"\x00" + _lx(suite, b"", b"psk_id_hash", b"") + _lx(suite, b"", b"info_hash", b"agentj-hook-seal-v1\n" + hook_id.encode())
    s = _lx(suite, shared, b"secret", b"")
    return enc + ChaCha20Poly1305(_le(suite, s, b"key", ctx, 32)).encrypt(_le(suite, s, b"base_nonce", ctx, 12), plaintext, b"")
```

Then send the sealed bytes with any HTTP client, for example curl:

```text
ts=$(date +%s)   # X-Signature = hex(HMAC-SHA256(secret, ts + "." + original body))
curl -X POST "$URL" --data-binary @sealed.bin -H "Content-Type: application/octet-stream" \
  -H "X-AgentJ-Seal: v1; kid=$KID" -H "X-Timestamp: $ts" -H "X-Signature: $SIG"
```

Node 18+ (no packages):

```js
// Node 18+, no packages. Same inputs; returns the request body (Buffer).
const c = require("node:crypto");
const lx = (suite, salt, label, ikm) => c.createHmac("sha256", salt.length ? salt : Buffer.alloc(32))
  .update(Buffer.concat([Buffer.from("HPKE-v1"), suite, Buffer.from(label), ikm])).digest();
const le = (suite, prk, label, info, n) => { const l = Buffer.alloc(2); l.writeUInt16BE(n);
  return c.createHmac("sha256", prk).update(Buffer.concat([l, Buffer.from("HPKE-v1"), suite, Buffer.from(label), info,
    Buffer.from([1])])).digest().subarray(0, n); };
function seal(pubB64u, hookId, plaintext) {
  const pk = Buffer.from(pubB64u, "base64url");
  const { publicKey, privateKey } = c.generateKeyPairSync("x25519");
  const enc = Buffer.from(publicKey.export({ format: "jwk" }).x, "base64url");
  const dh = c.diffieHellman({ privateKey, publicKey: c.createPublicKey({ key: { kty: "OKP", crv: "X25519", x: pubB64u }, format: "jwk" }) });
  const kem = Buffer.from("KEM\x00\x20", "latin1"), suite = Buffer.from("HPKE\x00\x20\x00\x01\x00\x03", "latin1");
  const shared = le(kem, lx(kem, Buffer.alloc(0), "eae_prk", dh), "shared_secret", Buffer.concat([enc, pk]), 32);
  const ctx = Buffer.concat([Buffer.from([0]), lx(suite, Buffer.alloc(0), "psk_id_hash", Buffer.alloc(0)),
    lx(suite, Buffer.alloc(0), "info_hash", Buffer.from("agentj-hook-seal-v1\n" + hookId))]);
  const s = lx(suite, shared, "secret", Buffer.alloc(0));
  const ci = c.createCipheriv("chacha20-poly1305", le(suite, s, "key", ctx, 32), le(suite, s, "base_nonce", ctx, 12), { authTagLength: 16 });
  return Buffer.concat([enc, ci.update(plaintext), ci.final(), ci.getAuthTag()]);
}
```

## Examples

```bash
agentj hook create --preset callback --name video-123 --note "Video job 123 finished" --json
```

```bash
agentj hook create --preset stripe --name stripe-payments
```

```bash
agentj hook logs <id>
```

The first one makes a one-time callback, the second a resident Stripe hook (approve the card on your phone), the third
shows what a hook has received. Every option: [agentj command reference](/docs/cli/).
