# Webhooks
URL: https://openship.io/docs/api/webhooks.md

Create incoming actions and inspect provider deliveries.

Incoming hooks trigger a saved deployment or job action. Provider webhooks receive signed events from
services such as GitHub. Both record deliveries, but their authentication protocols differ.

## Create an incoming hook

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const hook = await ship.webhooks.create("proj_123", {
  name: "Deploy site",
  actionType: "deploy",
  authMode: "token",
});
console.log(hook.id, hook.url);
```

</Tab>
<Tab value="REST API">

```bash
curl -X POST "$OPENSHIP_URL/api/projects/proj_123/incoming-webhooks" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Deploy site","actionType":"deploy","authMode":"token"}'
```

</Tab>
</Tabs>

| Field | Use |
| --- | --- |
| `actionType` | Required: `deploy` or `job`. |
| `name` | Display name; defaults to `Webhook`. |
| `authMode` | `token` (default), `hmac`, or `none`; `none` is not allowed for jobs. |
| `actionConfig.serviceId` / `serviceIds` | Restrict a deploy action to services within this project. |
| `actionConfig.jobKey` | Required for a job action. |

The returned hook includes its URL and, where authorized, a secret. Store that secret securely.
Job hooks require project-write, job-write, and administration access to every job target server.

## Invoke an authorized action

Use this operation when your integration is already authenticated with Openship:

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.webhooks.invoke("proj_123", "hook_123");
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
curl -X POST "$OPENSHIP_URL/api/projects/proj_123/incoming-webhooks/hook_123/invoke" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN"
```

</Tab>
</Tabs>

An external sender instead calls the hook's returned URL using its token or HMAC authentication.
The dispatch rechecks the saved execution identity, current membership, and any saved credential limits.
It does not replace the caller with the organization owner.

Hooks that lack saved authority return `requiresReauthorization: true`. An authorized editor can rotate
the hook or update it with `{ enabled: true }` to reauthorize it. Authority changes rotate the credential;
update external senders to use the new secret.

## Read deliveries

`deliveries(projectId)` lists a project's events. `hookDeliveries(projectId, hookId)` selects one hook;
`listDeliveries()` lists events visible across the selected organization. Pagination filters access
before selecting the page.

## Dispatch a provider webhook

```
POST /api/webhooks/:provider
```

There is no Openship-defined request body: the body is the provider's **raw event payload**, read verbatim
so the signature can be checked byte-for-byte. GitHub deliveries carry these headers, which the dispatcher
relies on:

| Header | Purpose |
|---|---|
| `X-Hub-Signature-256` | HMAC-SHA256 of the raw body (`sha256=…`). Required — a missing or wrong value is rejected. |
| `X-GitHub-Event` | Event type (`push`, `installation`, `check_run`, `ping`, …). Selects the handler. |
| `X-GitHub-Delivery` | Unique delivery id. Used for idempotency — a redelivered id is claimed once and dropped on repeat. |

The dispatcher:

1. Looks up the provider named in the path. Unregistered → `404 { "error": "Webhook provider '…' is not configured" }`.
2. Verifies the signature. Invalid or missing → `401`.
3. Parses the JSON body. Malformed → `400`.
4. Runs the handler and **always responds `200`** for a verified delivery — even if the handler throws, the
   error is returned in the body (`{ "success": false, "error": "…" }`) rather than as a `4xx/5xx`. Returning
   an error status would make GitHub retry and risk duplicate deployments.

```bash
# GitHub sends this automatically. Illustrative manual delivery — the signature
# must be a valid HMAC-SHA256 of the exact body using the project's webhook secret:
curl -X POST "$OPENSHIP_URL/api/webhooks/github" \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Event: ping" \
  -H "X-GitHub-Delivery: $(uuidgen)" \
  -H "X-Hub-Signature-256: sha256=<hmac-of-body>" \
  -d '{"zen":"Keep it simple."}'
```

### Signature verification (GitHub)

GitHub deliveries are verified with a **per-project** secret. The dispatcher peeks at `repository.full_name`
in the payload, finds the project(s) linked to that `owner/repo`, and checks the signature against the
project's stored `webhookSecret`. If no per-project secret resolves (legacy hooks, installation/ping events
that have no owning project), it falls back to the instance's `GITHUB_WEBHOOK_SECRET`. A delivery with no
resolvable secret cannot be verified and is rejected — there is no unsigned path, even self-hosted.

### Provider registration

`github` is registered on the dispatcher at startup, so `POST /api/webhooks/github` is always live. `stripe`
is an accepted `:provider` name but is **not** wired to the unified dispatcher by default; Stripe billing
events are received at `/api/billing/webhook/stripe` in cloud mode instead. Posting to
`/api/webhooks/stripe` when no Stripe provider is registered returns `404 … is not configured`.

## How a GitHub push becomes a deploy

When a verified `push` event arrives, the handler maps it to deployments like this:

- **Branch pushes only.** The `ref` must start with `refs/heads/`; tag pushes and branch deletions are
  acknowledged and ignored.
- **Match projects by repo + branch.** It finds every project linked to the pushed `owner/repo`, then keeps
  those where **auto-deploy is enabled** and the project's configured branch (or, if unset, the repository's
  default branch) equals the pushed branch.
- **Attribute to the org owner.** Webhooks have no human actor, so the deployment is audited against the
  organization owner (who owns the GitHub App installation).
- **Smart per-service routing.** For monorepo/compose projects, the changed files in the push decide which
  services rebuild; unaffected pushes can be skipped entirely. Single-app projects always rebuild. A pending
  "rebuild everything next time" flag forces a full deploy once.
- **Idempotency.** The `X-GitHub-Delivery` id is claimed persistently, so an at-least-once redelivery of the
  same push is dropped instead of deploying twice.
- **Cloud forwarding.** On a self-hosted box, if no local project matches but a linked **cloud** project owns
  the repo/branch, the push is forwarded to Openship Cloud so the cloud copy redeploys.

Deep dive on wiring and edge cases: [Auto-deploy on push](/docs/guides/auto-deploy).

## Errors

<Callout title="401 — invalid or missing signature" type="warn">
The `X-Hub-Signature-256` header is absent, or the HMAC doesn't match the stored secret. This is expected
for hand-crafted requests; genuine GitHub/Stripe deliveries are signed. If real deliveries fail, the webhook
secret on the project (or `GITHUB_WEBHOOK_SECRET`) no longer matches what the provider is signing with —
re-link the repository to reset it.
</Callout>

<Callout title="404 — unknown or unconfigured provider" type="error">
The `:provider` isn't `github`/`stripe`, or the named provider isn't registered on this instance (e.g.
`stripe` on a self-hosted box). Check the path and use `/api/webhooks/github` for GitHub.
</Callout>

<Callout title="400 — invalid JSON body" type="warn">
The signature verified but the body didn't parse as JSON. Confirm the provider is sending
`Content-Type: application/json` and the payload isn't truncated (the 5 MB body cap applies).
</Callout>

<Callout title="200 with success: false" type="info">
A verified delivery whose handler threw still returns `200` — on purpose, so GitHub doesn't retry into a
duplicate deploy. The failure detail is in the response body; check your instance logs for the
`[Webhook] github handler error` entry.
</Callout>

## Operations

{/* api-operations:start */}

### Resource methods

| Operation | SDK | REST API |
| --- | --- | --- |
| List the project’s incoming webhooks. | `webhooks.list(id)` | `GET /api/projects/:id/incoming-webhooks`<br />`project:read` |
| Create an incoming hook with an authorized action and authentication mode. | `webhooks.create(id, input)` | `POST /api/projects/:id/incoming-webhooks`<br />`project:write` |
| List project webhook deliveries. | `webhooks.deliveries(id, input?)` | `GET /api/projects/:id/webhook-deliveries`<br />`project:read` |
| Update a hook and reauthorize its action when requested. | `webhooks.update(parentId, id, input)` | `PATCH /api/projects/:id/incoming-webhooks/:hookId`<br />`project:write` |
| Rotate hook credentials and reauthorize execution. | `webhooks.rotate(parentId, id)` | `POST /api/projects/:id/incoming-webhooks/:hookId/rotate`<br />`project:write` |
| Remove an incoming hook. | `webhooks.remove(parentId, id)` | `DELETE /api/projects/:id/incoming-webhooks/:hookId`<br />`project:write` |
| Read one hook’s delivery history. | `webhooks.hookDeliveries(parentId, id, input?)` | `GET /api/projects/:id/incoming-webhooks/:hookId/deliveries`<br />`project:read` |
| Invoke the hook’s saved action through the SDK. | `webhooks.invoke(parentId, id)` | `POST /api/projects/:id/incoming-webhooks/:hookId/invoke`<br />`project:write` |
| Read webhook deliveries visible in the organization. | `webhooks.listDeliveries(input?)` | `GET /api/settings/webhook-deliveries`<br />`settings:read` |

### HTTP endpoints

| Operation | SDK | REST API |
| --- | --- | --- |
| Receive a provider webhook (`github` or `stripe`), verify its signature, and dispatch it. | HTTP only | `POST /api/webhooks/:provider`<br />`Handler authentication` |
| Trigger a backup from an inbound webhook (bearer token is the credential). | HTTP only | `POST /api/webhooks/backup`<br />`Handler authentication` |
| Authenticate an incoming hook and dispatch its saved action. | HTTP only | `POST /api/webhooks/incoming/:id`<br />`Handler authentication` |

{/* api-operations:end */}
