> ## Documentation index
> Fetch the complete index at: https://maritime.sh/llms.txt
> Setup procedure for AI agents (install, sign in, deploy, verify): https://maritime.sh/agents.md
> This page as HTML: https://maritime.sh/docs/sdk/webhooks

# Webhooks

A Maritime webhook is an HTTPS endpoint of yours that Maritime POSTs a signed JSON event to when a customer's agent
changes state, so you do not have to poll. Sandboxes have their own event stream, covered under
[Sandbox events](https://maritime.sh/docs/sdk/webhooks#sandbox-events).

## Events

Each delivery carries the agent's `external_id` and an `end_user` object (or `null`), so you route it to your own
customer without a lookup. These are all 14 events; a subscription with no events listed gets every one.

| Event | Fires when |
| --- | --- |
| `agent.deployed` | the agent finished deploying and is ready |
| `agent.error` | the agent entered an error state. A failed build is told apart from other failures by `data.failure_kind` |
| `agent.sleeping` | the agent auto-slept after going idle |
| `agent.woken` | a sleeping agent was woken |
| `agent.restarted` | the agent was restarted |
| `agent.stopped` | the agent was stopped |
| `agent.llm_limit_reached` | the agent reached its monthly AI spend limit; its AI calls stop until the next month or a higher limit. Sent once per agent per month |
| `message.reply` | an async API message got its reply |
| `message.failed` | an async API message failed |
| `exec.finished` | a detached exec reached a final status. `data` has `execution_id`, `status` and `exit_code`. `status` `"lost"` means the result was gone or never arrived before the timeout |
| `end_user.created` | an end user was created, by upsert or by createAgent |
| `end_user.suspended` | an end user was suspended |
| `end_user.resumed` | a suspended end user was resumed |
| `end_user.deleted` | an end user was deleted with their agents |

## Subscribe

Create a subscription once, from the SDK below or from the dashboard under
[Settings → Webhooks](https://maritime.sh/settings/webhooks). The signing `secret` is returned once. Store it to
verify deliveries. Endpoints must be public `https://` URLs.

### TypeScript

```typescript
const hook = await maritime.webhooks.create({
  url: 'https://your-app.com/maritime/webhook',
  events: ['agent.error', 'agent.deployed'],   // omit for all events
})
// hook.secret is shown once. Store it to verify signatures.
await maritime.webhooks.test(hook.id)          // deliver a synthetic ping
```

### Python

```python
hook = client.webhooks.create(
    url="https://your-app.com/maritime/webhook",
    events=["agent.error", "agent.deployed"],   # omit for all events
)
# hook["secret"] is shown once. Store it to verify signatures.
client.webhooks.test(hook["id"])                # deliver a synthetic ping
```

## Verify the signature

Every delivery is signed. Verify the `X-Maritime-Signature` header (an HMAC-SHA256 of the *raw* body with your
secret, as `sha256=<hex>`) before trusting the payload. Both SDKs export a verifier that compares in constant
time. **Fail closed** if the secret is unset. Never verify against an empty key.

### TypeScript

```typescript
import { verifyWebhookSignature } from 'maritime-sdk'

const SECRET = process.env.MARITIME_WEBHOOK_SECRET
if (!SECRET) throw new Error('MARITIME_WEBHOOK_SECRET is required')

export async function POST(req: Request) {
  const raw = await req.text()  // exact bytes: the signature is over these
  const ok = await verifyWebhookSignature({
    secret: SECRET,
    body: raw,
    signature: req.headers.get('x-maritime-signature'),
  })
  if (!ok) return new Response('bad signature', { status: 401 })
  const event = JSON.parse(raw)
  // { event: 'agent.error', agent_id, external_id, end_user, timestamp, data: {...} }
  console.log(event.event, 'for your customer', event.external_id)
  return new Response('ok')
}
```

### Python

```python
import json, os
from maritime import verify_webhook_signature

SECRET = os.environ["MARITIME_WEBHOOK_SECRET"]  # fails closed when unset

@app.post("/maritime/webhook")
async def hook(request: Request):
    raw = await request.body()  # exact bytes: the signature is over these
    if not verify_webhook_signature(SECRET, raw, request.headers.get("X-Maritime-Signature")):
        return Response(status_code=401)
    event = json.loads(raw)
    print(event["event"], "for your customer", event["external_id"])
    return Response("ok")
```

## Delivery and retries

Each delivery carries these headers:

- `X-Maritime-Event`: the event name
- `X-Maritime-Delivery`: the delivery id, the same on every retry
- `X-Maritime-Attempt`: 1 on the first try, then 2, 3 and so on
- `X-Maritime-Signature`: `sha256=<hex>` over the raw body
- `webhook-id`, `webhook-timestamp` and `webhook-signature`: the same delivery signed by the
  [Standard Webhooks](https://www.standardwebhooks.com) spec (`v1,<base64>` over `id.timestamp.body`), so a
  receiver can dedupe on the id and refuse an old timestamp

A delivery that does not get a 2xx is retried 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours later
(7 attempts over about 33 hours), then marked `dead`. Dedupe on the delivery id, since a retry can arrive after your
handler already acted. `webhooks.deliveries` shows the log of a subscription with each delivery's status, attempts
and last error, and `webhooks.replay` sends one again now.

### TypeScript

```typescript
const dead = await maritime.webhooks.deliveries(hook.id, { status: 'dead', limit: 50 })
for (const d of dead) await maritime.webhooks.replay(d.id)
```

### Python

```python
dead = client.webhooks.deliveries(hook["id"], status="dead", limit=50)
for d in dead:
    client.webhooks.replay(d["id"])
```

> **Note:** An endpoint that fails 20 times in a row is disabled and gets no more deliveries.
> `maritime.webhooks.list()` shows `isActive` for each. Turn one back on in the dashboard under
> Settings → Webhooks, or with `PATCH /api/v1/webhooks/{id}` and the body `{"isActive": true}`, which also resets
> the failure count.

## Sandbox events

[Sandboxes](https://maritime.sh/docs/ephemeral-sandboxes) send their own events to separate endpoints: `sandbox.created`,
`sandbox.running`, `sandbox.paused`, `sandbox.resumed`, `sandbox.ended`, `sandbox.pause_failed` and `sandbox.error`.
Register an endpoint with `maritime.sandboxWebhooks` (at least one event is required). These deliveries are signed with
a `Maritime-Signature: t=<unix seconds>,v1=<hex>` header, an HMAC-SHA256 of `<t>.<raw body>`; the verifier refuses a
timestamp more than 5 minutes off. Deliveries are kept for 30 days.

```typescript
const endpoint = await maritime.sandboxWebhooks.create({
  url: 'https://your-app.com/maritime/sandbox-events',
  events: ['sandbox.ended', 'sandbox.error'],
})
// endpoint.secret is shown once.

// In the receiver:
const ok = await maritime.sandboxWebhooks.verifySignature(raw, req.headers.get('maritime-signature'), SECRET)
if (ok) {
  const event = maritime.sandboxWebhooks.parseEvent(raw)  // { id, type, createdAt, sandboxId, endUser, data }
}
```

```python
endpoint = client.sandbox_webhooks.create(
    url="https://your-app.com/maritime/sandbox-events",
    events=["sandbox.ended", "sandbox.error"],
)
# endpoint.secret is shown once.

# In the receiver:
if client.sandbox_webhooks.verify_signature(raw, request.headers.get("Maritime-Signature"), SECRET):
    event = client.sandbox_webhooks.parse_event(raw)  # event.type, event.sandbox_id, event.data
```

### Runnable receivers

Full webhook receivers (Express + Flask) that verify the signature are in the
[examples](https://github.com/maritime-sh/maritime-sdk/tree/main/examples).
