webhooks.md

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.

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. The signing secret is returned once. Store it to verify deliveries. Endpoints must be public https:// URLs.

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

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

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

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:

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

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

Python

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

Sandbox events

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.

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