Deploy hooks¶
A deploy hook is a per-app opaque URL plus a secret. Anything that can make
an HTTP request — a CI pipeline, a GitHub Action curl step, a cron box — POSTs
to it and the bound app rebuilds and/or redeploys.
There are two flavors, and they have different ingresses:
| Flavor | Ingress | Credential |
|---|---|---|
| Deploy hook — POST from anything | POST /api/hooks/{hook_id} |
X-Hook-Secret |
Git push — git push → live |
POST /api/webhooks/git/{hook_id} |
GitHub HMAC signature / GitLab token |
⚠ Each ingress refuses the other's kind. A kind: "git" hook POSTed to
/api/hooks/{id} gets a 404, and a deploy hook cannot be driven from a provider
console. The create response returns the correct URL for the kind you asked
for — use its trigger_url rather than composing one (see
The trigger URL below).
Firing a hook¶
Public — no session or API key. The secret is the only credential.
curl -X POST -H 'X-Hook-Secret: dh_…' \
https://api.qa.appmecca.net/api/hooks/11111111-2222-3333-4444-555555555555
Authorization: Bearer dh_… is accepted as a fallback, because some CI systems
can only set that header. The secret is never in the URL — paths leak into
ALB, CloudFront and uvicorn access logs and into Referer.
A bare POST with no body is the normal case. An optional JSON body overrides the hook's stored defaults:
| Field | Effect |
|---|---|
action |
One of redeploy, rebuild, build_and_deploy. |
ref |
The branch to target. Must match ^[A-Za-z0-9._/-]{1,255}$. |
| Action | What happens |
|---|---|
redeploy |
Re-deploys the latest already-built image. No rebuild. 400 if nothing has been built for that branch. |
rebuild |
Builds the branch HEAD. Build only. |
build_and_deploy |
Builds the branch HEAD and deploys on success. |
"Latest commit" is handled by CodeBuild, which clones the branch HEAD. The API never resolves a SHA for the build path.
202 on success:
{
"accepted": true,
"action": "build_and_deploy",
"app_name": "alice-myapp",
"ref": "main",
"build_id": "…"
}
Responses¶
| Status | Meaning |
|---|---|
| 202 | Queued. |
| 404 | Every authentication failure — see below. |
| 409 | The app (or the target branch) is quarantined by the git-repo preflight, or the group is over its storage grant. |
| 422 | Post-authentication input error: unknown action, malformed ref. |
| 429 | Rate limited. |
git push → live¶
POST /api/webhooks/git/{hook_id}
X-GitHub-Event: push + X-Hub-Signature-256: sha256=…
…or…
X-Gitlab-Event: Push Hook + X-Gitlab-Token: …
Create the hook with kind: "git" and provider: "github" | "gitlab", then paste
the returned URL and secret into the provider's webhook settings. GitHub needs
content type application/json and "Just the push event"; GitLab needs "Push
events".
A verified push fires build_and_deploy — so action is forced to it and any
other value you send at create time is ignored — but only for a branch that passes
two gates (FEATURES #439):
- Something is serving on the branch.
deploy_status = 'running', or'failed'while an earlier release is still up (blue/green leaves the old colour serving). This is the same test the apps list uses to label a branchrunning. A branch that was never deployed, was undeployed, or whose status is anything else is not redeployed by a push. - Deploy-on-push is on for that branch —
apps.auto_deploy_on_push, one switch per branch, off by default (PUT /api/apps/{app}/auto-deploy, below). A push never creates an environment and never turns the switch on.
⚠ A new git webhook deploys nothing until you turn deploy-on-push on for a
branch. Branches that a live git webhook already reached, and that were serving,
when this shipped were switched on once, with an audit row (app.auto_deploy_changed,
actor seed:auto_deploy_on_push); nothing switches it on again automatically.
Deploy hooks (POST /api/hooks/{id}) are not gated — a CI call with a secret
is an explicit request to act.
Every pre-verification failure returns a byte-identical 401
{"detail": "Unauthorized"} — an unknown hook, the wrong provider, a missing
signature, a wrong signature and a tampered body are indistinguishable.
A verified push that the platform declines to act on returns 202 with an
ignored reason rather than an error, because an error would show every such
push as a failed delivery in the provider's console (and GitLab disables a
webhook that keeps failing). The delivery row's detail starts with the reason slug
("branch_mismatch: pushed 'dev', hook tracks 'main'"), and its ref carries
the pushed branch or tag, so the log shows which push was skipped:
ignored |
When |
|---|---|
ping |
GitHub's configuration ping, sent when the webhook is created (editing it later sends none — use Redeliver). It verified, so it proves the URL, the secret and a JSON content type — nothing is built for it |
not_a_push |
Any other event that is not a push (select push events only) |
tag_push |
The ref is a tag, not a branch (GitLab sends these as a separate Tag Push Hook) |
not_a_branch |
The ref is neither a branch nor a tag |
branch_deleted |
A branch delete, not a push |
malformed_payload |
The body verified but is not a push payload. For GitHub this is almost always the console's default content type: the detail says body was form-encoded, not JSON — set Content type to application/json |
unknown_provider |
Defensive: the parser's answer for a provider this platform does not support. A request with no provider event header is refused with the uniform 401 and writes no row |
branch_mismatch |
The pushed branch is not the hook's branch and any_branch is false |
no_matching_app |
The app has no environment for the pushed branch — a push never creates one, and only redeploys a branch that is serving with deploy-on-push on |
ambiguous_match |
More than one app row matched; nothing is deployed (see below) |
branch_not_running |
The branch has an environment, but nothing is serving on it — the detail names the real state (never deployed / undeployed / last deploy failed with no earlier release) |
auto_deploy_disabled |
The branch is serving, but its deploy-on-push switch is off — the detail names the exact mecca app auto-deploy … --on command |
duplicate_delivery |
The provider sent the SAME delivery again — an automatic retry, a GitHub Redeliver or a GitLab Resend — within 15 minutes of it queuing a build. Checked before every other gate. A resend of a delivery that did not queue a build is not a duplicate and runs normally |
Set any_branch: true to accept any branch of the same app.
⚠ any_branch widens the BRANCH, never the app. Matching is scoped to the
hook's own group and its bound app_name, and a residual multi-match is
ignored with a delivery row naming every candidate — a webhook that silently
fanned builds across apps would be a surprise-cost machine. Cross-group rows are
invisible to the match, so a canonical-name collision cannot be used to reach
another tenant.
What the payload is not trusted for¶
The repository in the payload is read for the audit trail and nothing else. What
gets built comes from the apps row the hook points at, so a forged payload
naming someone else's repo cannot redirect a build.
⚠ Nor is the pushed SHA used for the build. CodeBuild clones the branch and
builds its HEAD; after / checkout_sha are recorded and displayed only. A
push and its build can therefore legitimately show different commits if another
push lands in between.
Secret at rest — the one deliberate asymmetry¶
A GitLab token is presented verbatim, so the server only ever compares it
and stores sha256 like every other hook secret. A GitHub secret signs the
body, so the server must recompute HMAC-SHA256(secret, raw_body) and therefore
needs the original — it is stored reversibly.
⚠ Do not "fix" this by hashing the GitHub secret: it breaks every delivery, and silently, because the failure looks exactly like a mis-pasted secret.
Where the reversible secret lives
In the deploy_hooks row, not Secrets Manager. The API's IAM user holds
secretsmanager:GetSecretValue and no write action (a deliberate
data-plane narrowing), so it cannot mint an SM secret at all. The row is
excluded from every read projection, so no API response can return it.
A rejected delivery is still logged¶
A 401 against a live git hook writes a delivery row. That is deliberate: a mis-pasted provider secret is otherwise completely silent, and "I pushed and nothing happened" is the deliveries log's main purpose.
That includes a request with the wrong provider's event header — the URL of
a GitHub hook pasted into GitLab, or the reverse. Its row reads
result: "unauthorized" with a detail of provider_mismatch: a request with
GitLab's event header reached this URL, but this webhook was created for
GitHub. The caller still gets the same uniform 401.
⚠ An unauthorized row was NOT verified, so it proves only that a request
reached the URL — never who sent it. The provider is named by a request header
anyone holding the (semi-public) URL can send; the dashboard labels these rows
Not verified and words them as claims.
⚠ The ingress writes a row only for a live hook. An unknown, revoked or
disabled hook id writes nothing, whatever arrives — so a flood of guessed ids
cannot mint rows, and the per-hook rate limit bounds the rest. (The dry run
below is the one authenticated writer: it records a test row for any hook in
your scope, disabled or revoked included.)
How long deliveries are kept¶
30 days, except the most recent 200 per hook, which are kept regardless of age. A daily agent task prunes the rest.
The floor matters more than the window. Without it, an empty delivery log would
mean both "this hook has never fired" and "its history was pruned" — on the
one surface whose entire purpose is telling those two apart. 200 is the same
number as the limit parameter's maximum below, so the pruner always retains at
least as much history as the API will serve.
⚠ Rate limiting is not what bounds this table. The limiter bounds the write rate; only retention bounds the total. Successful and refused deliveries get the same window — the refused ones are the diagnostic ones, so expiring them sooner would invert the log's purpose.
Security model¶
Every authentication failure returns a byte-identical 404 {"detail": "Not
Found"} — a malformed id, a missing header, an unknown hook, a revoked hook,
a disabled hook, and a wrong secret are indistinguishable. The body is also
FastAPI's own 404 for an unmatched path, so a probe cannot even confirm the
route exists. The reason is logged server-side and never returned.
Errors after authentication are precise, because someone holding a valid secret needs to see what they got wrong.
The secret is stored only as sha256(secret) plus a 16-character prefix,
and compared with hmac.compare_digest. It is returned exactly once, at create
and rotate. No list or read path can recover it, and no audit row carries it —
only the prefix, which identifies the credential without being one.
⚠ One documented exception: a GitHub push hook's secret is reversible, because
the server has to recompute a signature from it rather than compare a presented
value — see Secret at rest under git push → live above. It is still excluded
from every read projection, so it is not returned by any endpoint.
Blast radius is one app. repository, config_path and the group context
come from the apps row the hook's app_id foreign key points at, never from
the request. A body may change the branch; it can never retarget another app,
repo or group.
A hook is never a preflight-quarantine bypass. When a ref targets a branch
other than the bound one, that branch's own row is resolved and gated —
preflight_status is per-row, so gating the bound branch and acting on another
would be a bypass. The authenticated --skip-preflight CLI path remains the
only escape from a false-negative check.
Rate limits are two independent layers: per hook (deploy_hook_rate_limit,
default 30/minute) so one noisy pipeline cannot starve others, and per client
IP (deploy_hook_rate_limit_ip, default 120/minute) which is what bounds a
caller holding no hook id at all.
The limit is not a security control
slowapi's in-memory storage is per-process, and the API runs
uvicorn --workers 2 × 3 replicas, so the effective fleet ceiling is ~6×
the configured value unless auth_rate_limit_redis_url is set. The real
throttle on abuse is that the endpoint only enqueues — the agent's queue
concurrency controls bound the actual work.
Revoke kills a leaked secret immediately (the next fire 404s); disable
pauses a hook without destroying its history. Every fire writes an
audit_events row with a NULL actor (machine-triggered) and a
deploy_hook_deliveries row.
Management¶
All authenticated and group-scoped. A hook id belonging to another app — or to a colliding app name in another group — returns 404, never 403.
| Method | Path | Scope |
|---|---|---|
POST |
/api/apps/{app}/hooks |
deploy |
GET |
/api/apps/{app}/hooks |
read |
GET |
/api/apps/{app}/hooks/provisioning |
read |
DELETE |
/api/apps/{app}/hooks/{hook_id} |
deploy |
POST |
/api/apps/{app}/hooks/{hook_id}/rotate |
deploy |
POST |
/api/apps/{app}/hooks/{hook_id}/provision |
deploy (session only) |
POST |
/api/apps/{app}/hooks/{hook_id}/reconcile |
deploy (session only) |
POST |
/api/apps/{app}/hooks/{hook_id}/enable |
deploy |
POST |
/api/apps/{app}/hooks/{hook_id}/disable |
deploy |
POST |
/api/apps/{app}/hooks/{hook_id}/test |
deploy |
GET |
/api/apps/{app}/hooks/{hook_id}/deliveries |
read |
PUT |
/api/apps/{app}/auto-deploy |
deploy |
POST /api/apps/{app}/hooks accepts {kind?, provider?, any_branch?, action?,
name?, ref_override?} and ?branch=. A hook binds to exactly one app on one
branch.
kind defaults to "deploy". For kind: "git" a provider is required
(github or gitlab) and action is forced to build_and_deploy; for
kind: "deploy" a provider is refused. Both refusals are a 422 naming the
field, rather than a 500 carrying a database constraint name.
201 returns the secret once:
{
"id": "1111…",
"secret": "dh_…",
"url": "/api/hooks/1111…",
"trigger_url": "https://api.qa.appmecca.net/api/hooks/1111…",
"warning": "Save this secret now — it is not shown again.",
"action": "rebuild",
"branch": "main",
"secret_prefix": "dh_abc123def456"
}
rotate replaces the secret in place — same id, same URL — so a compromised
secret is replaced without reconfiguring the caller's URL. It returns url and
trigger_url alongside the new secret. The list response never contains the
secret or its hash; each row carries url and trigger_url.
GET /api/apps/{app}/hooks?recent_deliveries=N (N from 1 to 10) also
attaches each hook's newest N deliveries as recent_deliveries. It is
opt-in: without the parameter the response is exactly what it was, and no
delivery query runs. When it is asked for, every hook carries the key —
[] for one with no deliveries — so a client can tell "none yet" ([]) from
"this API does not send them" (no key: an older API ignores the parameter).
Dry-run rows (result: "test") are not included — the inline list answers
"did a real delivery arrive?"; the full log below still shows them.
Could AppMecca create the webhook for you?¶
Answers, per git provider, whether your own linked account could let AppMecca create this app's push webhook — and exactly why not when it could not. It is what the dashboard's New webhook dialog shows under Automatic setup.
This endpoint is read-only: it writes to no provider, calls none, and
decrypts no token. provisioning_available is true — creating the webhook is
a separate, explicit call (provision),
and the dialog only offers it when can_provision is yes.
⚠ It is about the caller only. The answer reads the signed-in user's own
linked account — never the account an app's builds clone with, which may be
another member's. An API key has no linked account, so it gets
no_user_session for the provider the repository is on.
?branch= picks the app row exactly as creating a hook does; omitted, the
app's newest row. 200:
{
"app_name": "acme-docs",
"branch": "main",
"repository": "gitlab.com/acme/docs",
"repository_provider": "gitlab",
"provisioning_available": true,
"providers": {
"github": {
"provider": "github",
"can_provision": "no",
"reason": "repo_on_other_provider",
"required_scopes": ["repo"],
"missing_scopes": [],
"account": {"linked": false, "username": null, "needs_reauth": false},
"summary": "This app's repository is on gitlab.com, so its pushes come from GitLab — a GitHub webhook would never receive one.",
"action": "Choose GitLab as the provider.",
"provider_role_note": null
},
"gitlab": {
"provider": "gitlab",
"can_provision": "no",
"reason": "link_lacks_scope",
"required_scopes": ["api"],
"missing_scopes": ["api"],
"account": {"linked": true, "username": "jo", "needs_reauth": false},
"summary": "Your linked GitLab account (@jo) was connected without the api scope AppMecca would need to create a webhook for you. On GitLab that scope is account-wide: it can write to every project you can reach.",
"action": null,
"provider_role_note": "GitLab also requires the Maintainer or Owner role on the project."
}
}
}
can_provision is three-valued, and unknown is not a softer no: it
means AppMecca has no record of which scopes the provider granted (for example,
an account linked before AppMecca recorded them), so it will not claim either
way. A scope the platform merely asked for never counts as granted, and a
scope it never asked for counts as not granted — which is why a GitLab link,
whose request never includes api, answers a certain no.
reason |
can_provision |
Meaning |
|---|---|---|
repo_unresolved |
no |
AppMecca cannot tell which repository the app builds from |
repo_not_supported |
no |
The repository is not on github.com or gitlab.com — a push webhook cannot reach it; call a deploy hook from CI instead |
repo_on_other_provider |
no |
The repository is on the other provider — a webhook here would never receive a push |
no_user_session |
no |
The request is an API key, which has no linked account |
not_linked |
no |
You have not linked an account for this provider |
needs_reauth |
no |
The provider rejected your linked account's token; re-authorize it on your profile |
link_lacks_scope |
no |
Your account was linked without the scope creating a webhook needs (a GitLab link is read-only) |
scope_narrowed |
no |
AppMecca asked for the scope and the provider granted less |
scopes_unobserved |
unknown |
AppMecca has no record of what the provider granted; re-authorizing records it |
scope_granted |
yes |
Your account holds the scope. The provider still checks your role on the repository (provider_role_note) |
summary and action are written for a person and are the answer's own words;
render them rather than rebuilding them from reason. An API that predates
this endpoint answers the request 405 — treat that as no answer.
Create the webhook on the provider — provision¶
No body. Creates this git hook's push webhook on GitHub or GitLab with the
caller's own linked account, pointing at the hook's trigger_url, push events
only, TLS verification on. Nothing is sent to a provider except by this call (or
rotate / revoke on an already-provisioned hook) — creating a hook never does
it.
- Caller's token only. The write carries the signed-in user's own linked
token, never a teammate's or the app's clone identity. An API key cannot
provision — it has no linked account and is refused
no_user_session. - Scope and role. GitHub needs the normal
repolink plus admin on the repository. GitLab needs theapiscope — granted by linking with thelink_git_writemode (below) — plus Maintainer or Owner on the project. The provider checks the role; AppMecca cannot see it in advance. - Provisioning is a rotation. GitLab keeps only a hash of a webhook's
token, so AppMecca mints a new secret every time and returns it once. The
secret from
createstops working. - Adopt before create. If the repository already has a webhook whose URL
is this hook's
trigger_url, it is updated (re-keyed) rather than a second one created; several matches ⇒ the lowest id is updated and the rest reported inextra_matches. AppMecca never deletes a webhook it did not create. - The same call is the re-provision action for a hook whose
remote_stateisnone,divergedororphaned.
200:
{
"hook_id": "1111…",
"remote": {
"ok": true,
"reason": "provisioned",
"detail": "Created a push webhook on gitlab.com/acme/docs.",
"remote_hook_id": "4821",
"remote_repo_ref": "acme/docs",
"extra_matches": []
},
"hook": { "id": "1111…", "remote_state": "created", "…": "…" },
"secret": "dh_…",
"secret_prefix": "dh_abc123def456",
"trigger_url": "https://api.qa.appmecca.net/api/webhooks/git/1111…",
"warning": "Save this secret now — it is not shown again."
}
reason is provisioned (created) or adopted (an existing webhook re-keyed).
Refusals share one shape, whatever the route:
| Status | code |
|---|---|
| 409 | the capability reasons (not_linked, link_lacks_scope, scope_narrowed, scopes_unobserved, needs_reauth, repo_unresolved, repo_on_other_provider, no_user_session), trigger_url_unavailable, hook_not_git, hook_revoked, not_provisioned, permission, not_found, rejected |
| 429 | rate_limited |
| 502 | unreachable |
| 503 | token_unavailable |
message and action are written for a person; render them. sso_url is set
only when GitHub requires SAML SSO authorization for the organisation (an
https://github.com/… link to open). A refused provision changes nothing:
the hook and its existing secret are exactly as they were, so the manual setup
steps still work.
Check the provider's copy — reconcile¶
Read-only. Lists the repository's webhooks with the caller's token and compares
them to the hook: present with our URL and push events ⇒ in_sync; missing, or
URL/events changed ⇒ diverged (and the row's remote_state becomes
diverged). It cannot compare the secret — no provider returns it — so a
secret changed by hand on the provider is not detected. A hook never
provisioned is refused not_provisioned.
{"hook_id": "1111…", "remote": {"ok": true, "reason": "in_sync", "detail": "…"}, "hook": {"…": "…"}}
Rotate and revoke on a provisioned hook¶
Rotate is atomic when remote_state is created: the provider is updated
with the new secret first, and only then is it committed. If the provider
refuses, nothing changes — the old secret stays live on both sides — and the
refusal carries "local_only_available": true. To rotate anyway, send:
The new secret is committed here only; the row becomes diverged and
remote.reason is local_only, with remote.detail naming the step to do by
hand on the provider (until you do, pushes are refused as not verified). A hook
that was never provisioned rotates exactly as before; the 200 response carries
"remote": null then.
Revoke never fails because of the provider. The hook is revoked locally
first, then AppMecca removes the provider's webhook with the caller's token. The
response gains remote — null for a hook never provisioned, else
{ok, reason, detail, action}. When removal failed, ok is false, the row
becomes orphaned, and detail names the manual step; the daily sweep keeps
retrying.
⚠ Disabling a provisioned hook does not touch the provider: it keeps sending pushes, and AppMecca ignores them until you re-enable it.
Hook row fields¶
Every hook row (list, create, provision…) carries:
| Field | Meaning |
|---|---|
remote_state |
none · created · orphaned (revoked here, still on the provider) · diverged (the provider's copy no longer matches) · removed |
remote_hook_id |
the provider's webhook id |
remote_repo_ref |
owner/name it was created on |
remote_provisioned_by |
user id of the account that created it |
remote_provisioned_at |
when |
remote_last_reconciled_at |
last provider check |
remote_detail |
a sentence about the last outcome — show it on the row |
An older API omits them; treat a missing remote_state as none.
Granting webhook access — the link_git_write OAuth mode¶
Identical to link_git (a session is required, the callback is the same and
returns {linked, provider, username, scopes}), except that it asks for the
write scope: GitLab openid read_api read_repository api, GitHub repo. It
replaces the user's existing link for that provider.
⚠ On GitLab, api is account-wide. It upgrades the access AppMecca holds
for everything the user does with GitLab — clones included — not just webhooks
on one project. Say so in any consent copy. A later plain link_git re-link
drops it again; hooks already provisioned keep working, but AppMecca can no
longer rotate or remove them for you.
Unlinking an account removes its webhooks¶
now answers 200 (previously 204). Before the link is deleted, AppMecca removes every webhook that account provisioned; it never refuses the unlink.
{
"unlinked": true,
"provider": "gitlab",
"remote_hooks": {
"removed": 2,
"left": [
{"app_name": "acme-docs", "remote_repo_ref": "acme/docs", "remote_hook_id": "4821",
"reason": "permission", "manual_step": "Delete the webhook in the project's Settings → Webhooks."}
]
}
}
The daily sweep¶
Once a day the API retries removing orphaned provider webhooks with the token
of the user who created them (giving up after 10 provider failures and leaving
a platform warning), re-checks created hooks (flipping to diverged on a
mismatch), and finishes a provision interrupted after the provider call (adopting
the webhook by URL and re-keying it). It never creates a webhook. It goes
report-only when the creator's link is gone or needs re-authorization.
Several pushes in a row — coalescing and duplicates¶
Coalescing. When a hook queues work for a branch, every OLDER build or deploy
that same hook queued for that same branch, with the same action, that no
agent has picked up yet is cancelled as superseded — its row reads
cancelled with superseded_by=<the new action id>, and its queued deploy is
cancelled with it. Ten pushes in a minute cost about two builds: the one already
running, and the newest. It applies to both hook kinds (a git push and a
POST /api/hooks/{id}); it never cancels work that is already running, work
queued by a different hook, another branch's work, or anything a person or the
platform queued. It changes how many builds run, never what is built — a build
always clones the branch's latest commit. A queued response and its audit row
list what it superseded in superseded.
Duplicates. A git push delivery carries the provider's delivery id —
X-GitHub-Delivery, or GitLab's Idempotency-Key (GitLab 17.4+; older GitLab
sends none, so its deliveries are never treated as duplicates). If that same
delivery already queued a build in the last 15 minutes, it is answered 202
duplicate_delivery and nothing is built again. Only a delivery that queued a
build counts: redelivering one that was ignored or refused (for example after you
turned deploy-on-push on) runs normally.
Deploy on push — the per-branch switch¶
Turns deploy-on-push on or off for one branch. enabled must be a JSON
boolean — "yes", 1 and "true" are refused 422, as is any other key, because
this value authorises every future push to redeploy the branch. branch is a
query parameter; omit it only when the app has exactly one registered branch —
with more, the request is refused 409 rather than guessed.
200:
{
"app_name": "alice-myapp",
"changed": true,
"branch": "dev",
"deploy_status": "running",
"serving": true,
"auto_deploy_on_push": true
}
changed: false means it was already that value (nothing is written, audit
included). serving is read back from the same test the push uses — serving:
false with the switch on is legal (turn it on before the first deploy) and means
pushes do nothing yet. Every change writes one app.auto_deploy_changed audit
row on the app's group. Turning it on never deploys anything by itself.
GET /api/apps/{app}/hooks?reachable_branches=true attaches, to every git
hook, the branches it can reach:
"reachable_branches": [
{"branch": "dev", "deploy_status": "running", "serving": true, "auto_deploy_on_push": false},
{"branch": "main", "deploy_status": "running", "serving": true, "auto_deploy_on_push": true}
]
An exact-branch hook lists its branch; an any_branch hook lists every branch of
the app. It is opt-in like recent_deliveries: without the parameter the
response is unchanged and no extra query runs; with it, every git hook carries the
key — [] for one that reaches nothing (a revoked hook) — and a deploy hook never
does. A branch is deployed by a push exactly when its entry reads serving: true
and auto_deploy_on_push: true.
Deliveries¶
GET …/deliveries?limit=N (1–200, default 50) returns the newest first:
result |
Meaning |
|---|---|
queued |
The hook's action was queued; queued_action_id names it |
ignored |
Verified and deliberately not acted on — detail starts with the reason slug (table above) |
preview_pending |
Accepted, but nothing will be built |
unauthorized |
Not verified: a request to a live git hook whose signature (GitHub) or secret token (GitLab) did not match, or that carried the other provider's event header (provider_mismatch: …). It proves a request arrived, not who sent it |
rejected / error |
Verified, but the platform refused (4xx) or failed (5xx) to queue it |
test |
A dry run from …/test (below) — not a real delivery |
A deploy hook records nothing for a wrong secret (every authentication failure
is the uniform 404) nor for a call its body makes invalid (a 422 for a bad
action or ref), so an empty deploy-hook log with a CI job that "ran" means
the call never authenticated or was refused — read the job's output.
Testing a git push webhook — the dry run¶
Runs a push to ref (omit it for the hook's own branch; a short name gets
refs/heads/, a value under refs/ is used as given) through the checks a
real delivery meets once its signature is verified — the hook's liveness, the
provider's parser, the branch match, the single-app match, and every gate that
is a function of the app row (preflight, storage, and the repository URL a
build would clone) — and reports what would happen. Nothing is queued. One
delivery row with result: "test" records that the check ran. A ref with
whitespace, a control character or a formatting (e.g. bidi) character is a
422. The dry run is limited to 10 per minute per hook (a 429 past
that).
{
"hook_id": "2222…",
"provider": "github",
"ref": "feature/x",
"verdict": "would_queue",
"reason": null,
"status_code": 202,
"detail": "would queue build_and_deploy of alice-myapp@feature/x",
"summary": "A push to feature/x from GitHub would be accepted and would queue a build-and-deploy of alice-myapp@feature/x.",
"not_proven": "This is a dry run: … only a real delivery proves those. …"
}
verdict |
Meaning |
|---|---|
would_queue |
A push would be accepted and would queue a build-and-deploy |
ignored |
A push would be accepted and ignored — reason is the slug a real delivery would be logged under |
preview_pending |
A push would be accepted, but nothing would be built |
refused |
A push would be refused — reason is revoked, disabled or precheck (a quarantined app or an exhausted storage grant — 409 — or a repository URL the build would refuse — 400), and status_code is what the live ingress would answer |
⚠ Green does not mean the webhook works. The dry run never involves the
provider and verifies no signature — it accepts no signature, token or payload
field at all, because an endpoint that checked caller-supplied bytes against
the stored secret would be a forging oracle. So it cannot prove the provider can
reach the platform, that the secret saved there matches, or that the webhook is
installed, or that the provider is set to send push events (for GitHub, as
JSON: the dry run's payload is synthesised, so a hook left on GitHub's default
form-urlencoded content type still dry-runs green) — not_proven says so in
every response. Green means "would queue a build-and-deploy", never "would
build": git credentials, ECR and CodeBuild are only met by a real push. Only a
real delivery proves those: GitHub's configuration ping (sent when the
webhook is created) or Redeliver, GitLab's Test → Push events, or a push
— then read the deliveries log.
A deploy hook is refused with a 422: a dry run replays a git provider's push. Test a deploy hook by calling its URL with its secret and reading its deliveries.
The trigger URL — url vs trigger_url¶
Create, rotate and list return both:
| Field | Value | Use it for |
|---|---|---|
trigger_url |
The absolute, public URL — https://api.{env}.appmecca.net/api/hooks/{id}, or …/api/webhooks/git/{id} for a git hook. Built by the API from its own canonical hostname. |
What a CI system POSTs to, and what you paste into a GitHub / GitLab webhook console. |
url |
The relative path — /api/hooks/{id} / /api/webhooks/git/{id}. |
Only as a fallback: join it onto a base URL you know is reachable from where the hook will be called. |
trigger_url is always present in a response from a current API, and is ""
when the API cannot vouch for its own public address — no public domain is
configured (local development), or the platform's hostname alias map could not
be read or has no entry for the API. In that case the API refuses to guess
rather than hand out a hostname it cannot prove is its own. Treat "" exactly
like an absent key — fall back to joining url onto a base you know is
right — and test it for emptiness, not for null
(trigger_url || join(base, url)), or an empty string becomes the URL.
⚠ url stays relative, permanently. Clients that already join it onto a
base keep working unchanged. Do not join url onto whatever address your
process reached the API on and hand the result to someone else: an in-VPC
caller reaches the API on an internal address no CI system can use, which is
exactly how the dashboard came to show an …appmecca.internal URL (BUGS #1398).
The list and delivery endpoints are explicitly no-cache: they are a credential
surface, and a revoke that stays invisible for even ten seconds is the wrong
behaviour.