Skip to content

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.

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:

Terminal window
jiayang webhook add acme https://hooks.example.com/jiayang --event app.deploy --event grant.put

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

Terminal window
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.

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

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

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_requested and app.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.run and postgres.check. Also app.files_failed, app.kv_failed and app.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, and postgres.refused, a database that didn’t connect. Neither changed anything.
  • Billing records about Stripe rather than the plan, like billing.link, billing.update, billing.refund and the usage. entries, and our emails, mail.sent and mail.skipped.
  • workspace.create, workspace.delete, workspace.purge and sso.kept_sign_in: a workspace has no webhooks before it’s made or after it’s deleted.

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.

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:

  1. Read t and every v1 from the header.
  2. Refuse it if t is more than 5 minutes from now. This stops someone who saw a delivery from sending it again later.
  3. Work out the HMAC-SHA256 of t, . and the body exactly as it arrived, before any JSON parsing.
  4. 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 hashlib
import hmac
import 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 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.

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:

Terminal window
jiayang webhook list acme
jiayang 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:

Terminal window
jiayang webhook edit acme <id> --event app.deploy --event access_request.create
jiayang webhook edit acme <id> --off

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

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.