Skip to content

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

POST /api/hooks/{hook_id}
X-Hook-Secret: dh_…

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):

  1. 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 branch running. A branch that was never deployed, was undeployed, or whose status is anything else is not redeployed by a push.
  2. 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?

GET /api/apps/{app}/hooks/provisioning?branch=main

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

POST /api/apps/{app}/hooks/{hook_id}/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 repo link plus admin on the repository. GitLab needs the api scope — granted by linking with the link_git_write mode (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 create stops 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 in extra_matches. AppMecca never deletes a webhook it did not create.
  • The same call is the re-provision action for a hook whose remote_state is none, diverged or orphaned.

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:

{"detail": {"code": "permission", "message": "…", "action": "…", "sso_url": null}}
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

POST /api/apps/{app}/hooks/{hook_id}/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:

{"local_only": true}

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.

POST /api/auth/oauth/{provider}/start   {"mode": "link_git_write", …}

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

DELETE /api/users/me/accounts/git/{provider}

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

PUT /api/apps/{app}/auto-deploy?branch=dev
{"enabled": true}

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

POST /api/apps/{app}/hooks/{hook_id}/test
{"ref": "feature/x"}

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.