Workspace webhooks
A workspace webhook sends the workspace’s events to a URL of yours as they happen: a deploy, an app shared with someone, a new member, a new bypass token. Each one is a POST with a JSON body, signed so you can tell it came from us. Use one to feed a chat bot, a ticket queue or your own records.
To have us check signatures on webhooks sent to your app, see Webhooks and public paths instead. To post to a Slack channel, see Slack.
Webhooks are on every plan. Only a workspace’s owners and admins can add, read and remove them, since a webhook gets what the audit log has. A workspace can have 10 webhooks and Slack channels together.
When someone adds a webhook, turns one back on or gives one more events, every owner of the workspace who has signed in to Jiayang Cloud gets an email saying who did it and where it sends. It comes within a quarter of an hour, with every change since the last one, and nobody gets more than ten of these a day. A workspace can make those changes 30 times a day; turning a webhook off, or choosing fewer events, is never held back. A webhook keeps sending only while whoever added it is still an owner or admin of the workspace. Once they aren’t, we turn it off, and the audit log says so as a hook.update with reason orphaned. Whoever turns it back on becomes the one it depends on.
Add a webhook
Section titled “Add a webhook”In the dashboard, open the workspace’s Webhooks page. It’s in the sidebar, and linked from Settings. Select Add webhook, give the URL and choose the events, or keep them all. If you signed in more than 15 minutes ago, we ask you to sign in again first, as we do for a bypass token.
From the CLI:
jiayang webhook add acme https://hooks.example.com/jiayang --event app.deploy --event grant.putLeave out --event to get every event. We show the signing secret once, when the webhook is made. It starts with jyw_. Keep it where your endpoint can read it, like a secret in your own app. Nobody can see it again, us included, so if it’s lost, remove the webhook and add a new one.
The URL has to start with https://, name a public host, and use port 443 or 8443. We refuse an address on a private, loopback or link-local network, however it’s written, and we check the host’s addresses again every time we send. We also refuse a name nobody can register, like one under .internal or .local, since it can never be reached. A URL that’s a Slack incoming webhook goes in as a Slack channel instead.
The URL can’t point at Jiayang Cloud itself: our dashboard and API, our sign-in, or any app’s address here, an app’s public path included. The service that sends webhooks is part of the platform, and we keep anything a workspace types in from making it send requests to the platform’s own hosts. To receive events in an app, host the receiver somewhere else.
Many services put a key in the webhook URL itself, in the path, the query or even the host name, so we keep the whole URL encrypted. Afterwards, the dashboard, the audit log and our emails show only the domain it’s under, like https://example.com/….
So that several webhooks to one domain can be told apart, give each a name when you add it, up to 60 characters, and we show it beside the domain:
jiayang webhook add acme https://hooks.example.com/jiayang --name "deploys to ops"jiayang webhook rename acme <id> "deploys to ops"On the Webhooks page, Name it or Rename does the same. Renaming sends nothing anywhere new, so it doesn’t ask you to sign in again.
Events
Section titled “Events”Leave out --event, or keep every box ticked, and a webhook gets every event below. Events we add later aren’t added to a webhook that already exists: choose them under Choose events on its panel. Each event’s data is the entry’s detail in the audit log, so the log and a delivery always agree. It never holds a secret’s value, a key, a token, a file’s name or the note someone wrote with a request.
| Event | When | data |
|---|---|---|
app.create |
An app was made | slug, driver |
app.update |
Its name or who can see it changed, AI clients were let in or stopped, or a container app’s number of containers changed | name, visibility, mcp or containers when it changed, and was with the values before |
app.delete |
An app was deleted | app_id, slug, and until when it can be restored, in restorable_until. Deleted for good at once, domains it answered on instead, when it had any |
app.restore |
A deleted app was restored | app_id, slug |
app.purge |
A deleted app was deleted for good | app_id, slug, deleted_at, and domains it answered on |
app.database_restore |
An app’s database was restored to an earlier time | at and restored_to, or bookmark; previous_bookmark, or outcome unknown when D1 never said whether it ran |
app.deploy |
A version went live | version |
app.deploy_failed |
A version didn’t go live | version, reason, and by or message for some reasons |
app.scan |
A deploy’s scan found a possible secret or a vulnerable package, or the allow file accepted one | version, scan_id, outcome, the counts (secrets, critical, high, moderate, low, unknown, allowed, packages), dependencies, policy |
app.deploy_blocked |
The scan refused a deploy | the same as app.scan, with no version |
app.rollback |
An app went back to an earlier version | version |
app.put_back |
We undid a deploy, because whoever made it could no longer edit the app | version, and put_back with the version now live, or taken_off: true |
app.preview, app.promote |
A preview was put up, or made live | version. A pull request’s preview adds pull_request and branch |
app.preview_end |
A pull request’s preview was ended, as the pull request closed or by hand | pull_request and versions. When several end at once, as previews of pull requests are turned off, pull_requests instead |
app.public_paths |
The paths open to everyone changed | paths, every path open afterwards |
schedule.add, schedule.update, schedule.remove |
A scheduled job was added, changed or removed | schedule, cron, path, method. schedule.update adds enabled and turned: on, off or null |
Sharing and access
Section titled “Sharing and access”| Event | When | data |
|---|---|---|
grant.put, grant.remove |
An app was shared with someone, or their access was taken away | email, and role when shared, with was, the role they had before or null. still_shared_by names a group or domain that still gives them the app |
grant.group_put, grant.group_remove |
An app was shared with a group, or unshared | group, role when shared. reason: group_deleted when the group went |
grant.domain_put, grant.domain_remove |
An app was shared with everyone at a domain, or unshared | domain, role when shared |
access.refused_digest |
Once an hour, the visits we refused that hour | from, until, refused, and apps with each app’s slug, name and refused |
access_request.create |
Someone asked for access to an app | request_id, email. Never the note they wrote |
access_request.approve, access_request.deny |
Whoever can share the app approved or turned the request down | request_id, email |
token.create, token.revoke |
A bypass token was created or revoked | token_id, and role when created |
Members and groups
Section titled “Members and groups”| Event | When | data |
|---|---|---|
member.put |
Someone was added, or given a new role | email, role, and was, the role they had before or null for someone new |
member.remove |
Someone was removed | email, revoked_grants with the apps they lost, left_groups, and still_shared_by_domain for apps a domain share still gives them |
member.leave |
Someone left | role they had, revoked_grants, left_groups, and still_shared_by_domain. The actor is who left |
group.create, group.delete |
A group was made or deleted | group. One your identity provider made adds display_name. A deletion adds revoked_grants and members |
group.member_put, group.member_remove |
Someone was added to a group or taken out | group, email, apps the group is shared with |
group.update |
Your identity provider renamed a group | group, and display_name it has now |
role.create, role.delete |
A custom role was made or deleted | role, permissions |
role.update |
A custom role’s permissions or description changed | role, permissions it has now, and before, the permissions it had |
member.role |
A member was given a custom role or had it taken away | email, role they have now or null, and before |
group.role |
A group started or stopped giving a custom role | group, role it gives now or null, and before |
publish_policy.set |
A publish rule was set or changed | action, admins, members, roles, existing (keep or end), and was, the rule before or null |
publish_policy.remove |
A publish rule was taken off | action, and was |
publish_policy.end |
An owner ended what broke the publish rules | actions, and how many ended and were left |
publish_policy.refuse |
A publish rule refused someone | action, and what they tried: email, domain, group, paths, name or app |
A change your identity provider made over SCIM has "via": "scim" in its actor.
Agents and AI clients
Section titled “Agents and AI clients”| Event | When | data |
|---|---|---|
agent.create |
An agent was made | agent_id, name |
agent.rename |
An agent was renamed | agent_id, from, to |
agent.end |
An agent was ended | agent_id, name, credentials revoked, apps it lost |
agent.credential_create |
An agent got a credential | agent_id, name, credential_id, expires_at |
agent.credential_revoke |
An agent’s credential was revoked | agent_id, name, credential_id |
agent.grant, agent.ungrant |
An agent was given an app, or it was taken away | agent_id, name, and role when given |
oauth.connect |
Someone connected an AI client to an app | client_id, client_name, redirect_uri |
oauth.revoke, oauth.reuse |
A connection ended, or ended because a code or token of it was used twice | grant_id, client_id, client_name, email, why |
ai_clients.set |
The workspace changed whether any AI client may connect, or only the ones on its list | from, to (anyone or listed) |
ai_clients.allow, ai_clients.remove |
An AI client was added to the workspace’s list, or taken off it | entry: a client id, a client’s address, a host or loopback |
Sign-in
Section titled “Sign-in”| Event | When | data |
|---|---|---|
sso.set, sso.enforce, sso.remove |
Single sign-on was set up or changed, who must use it changed, or it was removed | protocol and issuer when set, enforce when enforced |
scim.token, scim.token_revoke |
A SCIM token was made, or revoked | hint, the token’s last characters, when made |
workspace_domain.add, workspace_domain.verify, workspace_domain.remove |
A domain for the workspace’s own sign-in was added, proved or removed | domain |
Secrets, outbound, AI keys and Postgres
Section titled “Secrets, outbound, AI keys and Postgres”| Event | When | data |
|---|---|---|
secret.put, secret.remove |
A secret was set or removed | name, and inject_host when set. Never its value |
egress.allow, egress.deny |
A host was added to an app’s outbound list, or taken off | pattern, version |
egress.restrict, egress.unrestrict |
The outbound list was turned on, or off so the app can send requests anywhere | Nothing |
ai_key.put, ai_key.remove |
A company AI key was stored, replaced or removed | provider, and hint when stored. Never the key |
ai_grant.put, ai_grant.remove |
An app was let use a company AI key, or stopped | provider, and monthly_budget_cents when let |
postgres.add, postgres.remove |
A Postgres database was connected, or removed | name, host, database and user, with port and tls when connected. Never the password |
postgres.update |
A connection was pointed elsewhere, given a new password, or had its TLS changed | name, changed (which of host, database, user, password and tls), and where it goes now |
postgres.grant, postgres.revoke |
An app was given a connection, or had it taken away | name |
Private network
Section titled “Private network”| Event | When | data |
|---|---|---|
private_network.add, private_network.remove |
A private network was added or removed | network |
private_network.token |
Someone was shown a network’s connector command | network |
private_service.add, private_service.remove |
A service was put on a network, or removed | service, network, host |
private_grant.put, private_grant.remove |
An app was let reach a service, or stopped | service, host |
egress_ips.assign, egress_ips.lift |
We gave the workspace’s apps fixed addresses, or took them away | addresses and dedicated when given |
Encryption key
Section titled “Encryption key”| Event | When | data |
|---|---|---|
workspace_key.add, workspace_key.remove |
The workspace’s own encryption key was added, or removed and everything moved back under ours | provider, key, aws_role or key_name, and resealed, how many values moved |
workspace_key.rotate |
The workspace moved to another key of its own | from and to, each with provider, key, aws_role or key_name |
workspace_key.revoke, workspace_key.restore |
An owner revoked the key, so nothing sealed under it opens, or restored it | Nothing |
workspace_key.discard |
An owner discarded a lost key, and everything sealed under it was deleted | How many of each went: secrets, webhook_verifiers, ai_keys, webhooks, audit_streams, held |
workspace_key.add_refused, workspace_key.discard_refused |
Adding or discarding a key was refused | Nothing |
Domains, GitHub and files
Section titled “Domains, GitHub and files”| Event | When | data |
|---|---|---|
domain.add, domain.remove |
A custom domain was added or removed | hostname |
domain.verified, domain.failed |
A custom domain started working, or stopped | hostname, failure |
github.set, github.remove |
A GitHub repository was allowed to deploy an app, or stopped | repository, and repository_id, repository_owner_id, branch, environment when set |
github.refused |
A GitHub Actions run was turned away | reason, ref, event, environment, repository_id, repository_owner_id, also_refused. The actor is the run |
app.files, app.files_refused |
Someone listed, downloaded, uploaded or deleted an app’s files, or was refused | op, and a keyed hash of the file’s name in sha256 with hash. Never the name |
app.files_expired |
An app’s files are being deleted, after the workspace stayed over its storage | reason |
app.kv, app.kv_refused |
An app was given key-value storage, or someone listed, read, set or deleted its keys, or was refused | op, and for a key a keyed hash of it in sha256 with hash. Never the key |
app.queue |
An app was given a queue, where its messages go changed or stopped, or someone sent it a message | op, path when it says where messages go, and for a message a keyed hash of it with its bytes. Never the message |
Variables, verified webhooks and data
Section titled “Variables, verified webhooks and data”| Event | When | data |
|---|---|---|
app.env |
An app’s environment variables were set or removed | set and unset, the variables’ names. Never their values |
webhook.set |
A verified webhook was set up on a public path, or replaced | pattern, provider, verifier_id, replaced when it replaced one, and its settings |
webhook.update |
A verified webhook’s tolerance changed | pattern, verifier_id, tolerance_seconds |
webhook.rotate, webhook.retire |
A verified webhook got a new secret, or stopped taking the old one | pattern, provider, verifier_id, and previous_accepted_until on a rotation. Never the secret |
webhook.remove |
A verified webhook was removed | pattern, provider, verifier_id, because |
app.database_export |
Someone exported an app’s database | Nothing |
app.database_exec |
Someone ran a statement on an app’s database | statement, its first word in capitals, delete_only, and a keyed hash of it in sha256 with hash. Never its text |
workspace.export |
Someone exported the workspace, or downloaded the audit log as CSV | Nothing for the workspace. For the CSV, format: csv, and since, until and app |
app.database_removed |
We deleted an app’s database, after the workspace stayed over its database limit | reason, bytes |
Refused changes
Section titled “Refused changes”| Event | When | data |
|---|---|---|
app.database_exec_refused |
A statement on an app’s database was refused, for example because the workspace is suspended | As for app.database_exec |
app.delete_refused, workspace.delete_refused |
Someone tried to delete an app or the workspace, and was refused | app_id and slug for an app, slug for the workspace |
secret.remove_refused, app.env_refused |
Someone tried to remove a secret or environment variables, and was refused | name for a secret, unset for variables |
webhook.remove_refused |
Someone tried to remove a verified webhook, and was refused | As for webhook.remove |
account.delete_refused |
A member tried to delete their own account, and the workspace kept it | Nothing. The actor is who tried |
A deletion is refused while the workspace is suspended, or when that thing can’t be deleted right now. The event never says why.
Plan, billing and suspensions
Section titled “Plan, billing and suspensions”| Event | When | data |
|---|---|---|
billing.checkout |
An owner started a subscription | plan |
billing.plan |
The plan changed | from, to, and at for a change that waits for the period’s end |
billing.plan_undo |
An owner undid a plan change that was waiting | kept, dropped |
billing.plan_payment, billing.plan_failed |
A plan change waits for its invoice to be paid, or didn’t go through | from, to, and why when it failed |
billing.usage_limit |
An owner set the usage limit, removed it, or went back to the default | cents, default, none |
billing.credit |
We credited the workspace | amount_cents |
plan.assign, plan.lift |
We put the workspace on Enterprise, or took it off | plan |
workspace.suspend, workspace.unsuspend |
The workspace was suspended, or the suspension lifted | reason |
app.suspend, app.unsuspend |
We took one app down, after a copyright notice for example, or put it back | reason |
These never carry an id of ours at Stripe, such as an invoice’s or an event’s: we take those out before sending. The audit log keeps them. A change our staff made has {"kind": "staff"} as its actor, with no name.
The workspace, its webhooks, streams and log drains
Section titled “The workspace, its webhooks, streams and log drains”| Event | When | data |
|---|---|---|
workspace.update |
The workspace’s name, allowed domains or scan policy changed | name, allowed_email_domains, or scan_policy and was |
workspace.restore |
A deleted workspace was restored | slug, deleted_at, and when it had SCIM, scim_held: how many people wait for your identity provider |
hook.add, hook.remove |
A webhook or Slack channel was added or removed | hook_id, kind, url as it’s shown, name, and events when added |
hook.update |
A webhook was turned on or off, renamed, or given other events | hook_id, enabled, events, name, and counted: true when it turned the webhook on or gave it more events, which count toward the day’s changes |
hook.failing |
We turned a webhook off after a day of failures | hook_id, kind, url |
audit_stream.add, audit_stream.remove |
An audit log stream was added or removed | stream_id, kind, destination as it’s shown |
audit_stream.update |
A stream was turned on or off | stream_id, enabled |
audit_stream.off |
We turned a stream off | stream_id, kind, destination, reason: failing, plan or orphaned |
log_drain.add, log_drain.remove |
A log drain was added or removed, for one app or every app | drain_id, kind, destination as it’s shown, and min_level when added |
log_drain.update |
A drain was turned on or off, or its level changed | drain_id, and enabled or min_level |
log_drain.off |
We turned a drain off | drain_id, kind, destination, reason: failing, plan or orphaned |
Requests let through to an app aren’t sent one by one. There are far too many. The audit log has each of them.
A few more of the log’s entries aren’t events, and the audit log has them all:
- Steps of something that already has an event:
app.env_applied,app.env_not_applied,image.push_token,image.push,github.credential,oauth.token,app.database_restore_requestedandapp.database_restore_started. workspace_key.use, each call we make to your own encryption key: about one every 40 seconds from each of our servers while your apps are busy.- Tests and reruns:
hook.test,audit_stream.test,audit_stream.retry,log_drain.test,log_drain.retry,schedule.runandpostgres.check. Alsoapp.files_failed,app.kv_failedandapp.queue_failed, a call to an app’s files, keys or queue that failed on our side. plan.limit, a refusal for the plan, which whoever tried was told of,app.database_restore_refused, a restore D1 refused, andpostgres.refused, a database that didn’t connect. Neither changed anything.- Billing records about Stripe rather than the plan, like
billing.link,billing.update,billing.refundand theusage.entries, and our emails,mail.sentandmail.skipped. workspace.create,workspace.delete,workspace.purgeandsso.kept_sign_in: a workspace has no webhooks before it’s made or after it’s deleted.
What we send
Section titled “What we send”Each event is a POST with these headers:
| Header | What it holds |
|---|---|
Content-Type |
application/json |
Jiayang-Signature |
t=<unix time>,v1=<signature> |
Jiayang-Event |
The event, like app.deploy |
Jiayang-Event-Id |
The event’s id. The same on every try |
Jiayang-Delivery |
This delivery’s id |
The body is the event as the audit log has it:
{ "id": "0b8e6c1e-3d4f-4c51-9d7a-2a41f3f0c9e2", "type": "grant.put", "created_at": "2026-10-02T14:05:31.204Z", "workspace": { "id": "5b1c…", "slug": "acme" }, "app": { "id": "9f2a…", "slug": "payroll", "name": "Payroll" }, "actor": { "kind": "user", "email": "ana@example.com", "sub": "usr_…", "via": "browser" }, "data": { "email": "bo@example.com", "role": "viewer" }}app is null for an event about the workspace, and data is what the audit log keeps for that event. A test event has the type ping.
Check the signature
Section titled “Check the signature”Jiayang-Signature holds a Unix time t and a signature v1. The signature is the HMAC-SHA256, in hex, of the time, a full stop, and the raw body, with your whole secret as the key, jyw_ included. This is the scheme Stripe uses.
To check a delivery:
- Read
tand everyv1from the header. - Refuse it if
tis more than 5 minutes from now. This stops someone who saw a delivery from sending it again later. - Work out the HMAC-SHA256 of
t,.and the body exactly as it arrived, before any JSON parsing. - Accept it if your value matches any
v1, compared in constant time.
In Node:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyJiayang(secret, header, rawBody, toleranceSeconds = 300) { let t = null const signatures = [] for (const part of (header ?? '').split(',')) { const [key, value] = part.trim().split('=') if (key === 't') t = Number(value) if (key === 'v1' && value) signatures.push(value) } if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest() return signatures.some((sig) => { const given = Buffer.from(sig, 'hex') return given.length === expected.length && timingSafeEqual(given, expected) })}In Python:
import hashlibimport hmacimport time
def verify_jiayang(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: t, signatures = None, [] for part in (header or "").split(","): key, _, value = part.strip().partition("=") if key == "t" and value.isdigit(): t = int(value) elif key == "v1": signatures.append(value) if t is None or abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in signatures)Answer, retries and turning off
Section titled “Answer, retries and turning off”Answer with any 2xx status within 10 seconds, and better within 5: do slow work after you answer. We don’t read the body of your answer, and we don’t follow a redirect: a 3xx counts as a failure, so give us the final URL.
A delivery that fails is tried again after a minute, then 2, 4, 8 minutes and so on, up to 6 hours apart, for a day. If it still fails after a day, we stop trying, turn the webhook off and email the workspace’s owners. Turn it back on from the Webhooks page once the endpoint works, or with jiayang webhook edit acme <id> --on. Events from while it was off aren’t sent.
A webhook whose maker is held after a restore, until your identity provider confirms them, isn’t off. It stays on and its deliveries wait, so nothing goes to it, a test included. The Webhooks page and jiayang webhook list say it’s waiting. Once your provider confirms them, or an owner or admin adds them again, the events from while they were held go out.
Deliveries go round the workspaces in turn, so one with a long queue doesn’t hold up the rest. A webhook that has failed three times in a row is tried once a minute, then less often, up to once an hour, until it answers again. One that keeps taking more than 5 seconds to answer gets one delivery a minute, so a busy workspace’s events can fall behind: answer first and do slow work after.
Each event arrives at least once. If we lose track of a delivery partway, we send it again with the same Jiayang-Event-Id, so skip an id you have already handled. Events can arrive out of order. Sort them by created_at if order matters.
Test and watch deliveries
Section titled “Test and watch deliveries”Send test event on the Webhooks page sends a ping now and shows how your endpoint answered. Each webhook takes 3 test events a minute. From the CLI:
jiayang webhook list acmejiayang webhook test acme <id>jiayang webhook test exits with 1 when the event wasn’t delivered, so a script or a CI check can tell.
The page lists each webhook’s recent deliveries with their status and your endpoint’s answer, for a week. A test isn’t tried again and doesn’t count towards turning a webhook off. A host we couldn’t reach and a host whose addresses are all private look the same there: we couldn’t connect.
To change which events a webhook or Slack channel gets, or turn one off or on, edit it on the page or from the CLI. --event replaces the events it had:
jiayang webhook edit acme <id> --event app.deploy --event access_request.createjiayang webhook edit acme <id> --offIts URL can’t change. To send to another, add a new webhook and remove this one. Turning one on, or giving it more events, needs a sign-in in the last 15 minutes in the dashboard. From the CLI it doesn’t.
Remove a webhook with jiayang webhook remove acme <id>, or from the page. Every add, change and removal is in the workspace’s audit log.
The weekly email
Section titled “The weekly email”Each Monday we email a workspace’s owners a summary of the week before: what was deployed, who opened which app, what was shared, the visits we refused, and this billing month’s usage charges against the usage limit. A week with nothing in it isn’t sent.
Every weekly email has an unsubscribe link, and mail apps that offer one-click unsubscribe show it too. It stops that workspace’s email for you at once. An owner can turn it back on under Weekly email on the Webhooks page.