Webhooks & CI/CD
When a designer publishes a release, Particles can push the regenerated token files straight into your codebase as a pull request. Webhooks fire a signed event; your CI pulls the immutable release snapshot and opens the PR. Webhooks and webhook secrets are a Team plan and above feature.
The pipeline#
Designer publishes release 1.4.0
│
▼ release.published (HMAC-signed, retried)
your CI / webhook receiver
│ GET /v1/projects/:id/releases/1.4.0/export?format=style-dictionary
▼ (the frozen release snapshot — deterministic)
regenerate token files → open a PR in your repoEvents#
A webhook subscribes to one or more event types. release.published is the one that drives CI/CD; the others power notifications and custom automations.
| Event | Fires when |
|---|---|
| release.published | A semver release snapshot is published — drives token-file CI/CD. |
| token.created / token.updated / token.deleted | A token changes on a branch. |
| token_request.opened / approved / merged / rejected | A Token Request changes state. |
| branch.created / branch.merged | A branch is created or merged. |
Create a webhook#
In Studio, go to Settings → Webhooks, pick a project, enter your receiver URL, and select the events. Each webhook gets a signing secret used to verify deliveries. You can Test a webhook, pause it, and inspect its recent deliveries (status, response code, attempt) from the same screen.
Delivery & payload#
Deliveries are POST requests with these headers:
| Header | Value |
|---|---|
| X-Particles-Event | The event type, e.g. release.published |
| X-Particles-Signature | sha256=<hmac> — HMAC-SHA256 over `timestamp + "." + body` |
| X-Particles-Timestamp | Unix seconds — reject if too far from now (replay protection) |
| X-Particles-Delivery | Unique delivery id — use as an idempotency key |
{
"event": "release.published",
"projectId": "018fde70-…",
"actor": "018fde70-…",
"timestamp": "2026-06-11T10:00:00.000Z",
"data": {
"releaseId": "018fde70-…",
"version": "1.4.0",
"branch": "main",
"notes": "Refresh brand palette",
"tokenCount": 142,
"exports": {
"tailwind-v4": ".../v1/projects/018fde70-…/releases/1.4.0/export?format=tailwind-v4",
"style-dictionary": ".../v1/projects/018fde70-…/releases/1.4.0/export?format=style-dictionary",
"dtcg": ".../v1/projects/018fde70-…/releases/1.4.0/export?format=dtcg"
}
}
}Verify the signature before trusting a delivery:
import { createHmac } from 'node:crypto'
function verify(secret: string, timestamp: string, rawBody: string, signature: string) {
const expected = 'sha256=' + createHmac('sha256', secret)
.update(timestamp + '.' + rawBody)
.digest('hex')
// constant-time compare in production
return expected === signature
}Deliveries that fail (timeout, 5xx, or 429) are retried automatically with backoff (≈30s → 2m → 10m → 30m). A 4xx other than 429 is treated as a permanent rejection and not retried. You can also redeliver any past attempt from the delivery log.
Webhook secrets (the CI credential)#
CI needs to call back into Particles to pull the release export. It uses a webhook secret — the only machine credential. It is deliberately narrow:
| Property | Behaviour |
|---|---|
| Read-only | Authenticates GET read routes only — it can never modify tokens, branches, or releases. |
| Project-scoped | Limited to an allowlist of projects (or all projects). A read for a project outside the list is rejected. |
| Revocable / deletable | Revoke stops it immediately (kept for audit); delete removes it entirely. |
| Shown once | The plaintext whsec_… is displayed only at creation time — store it in your CI secrets. |
Create one in Settings → Webhook Secrets (org admin), choose its project scope, and copy the whsec_… value once. Present it as a bearer token:
curl -H "Authorization: Bearer $PARTICLES_WEBHOOK_SECRET" \ "https://api.particles-ui.com/v1/projects/$PROJECT_ID/releases/1.4.0/export?format=style-dictionary"
Release-pinned export#
GET /v1/projects/:id/releases/:version/export?format=<format> returns the token files for an immutable release snapshot, so two CI runs of the same version produce byte-identical output. Formats: tailwind-v4, css, scss, style-dictionary, dtcg, json, ts, js.
{
"format": "style-dictionary",
"version": "1.4.0",
"files": [{ "path": "tokens.json", "contents": "{ … }" }]
}Export format & output path#
The export format, target directory, and filename are not part of the webhook or the secret — they live in the consuming repo's particles-ui.json (created by particles init), so each repo owns its own output. The webhook only triggers the run.
{
"projectId": "018fde70-0000-7000-8000-000000000001",
"format": "tailwind-v4",
"outputDestination": "src/styles/tokens.css"
}| Field | Controls |
|---|---|
| format | Export type — tailwind-v4, css, scss, style-dictionary, dtcg, json, ts, js. |
| outputDestination | Target directory + filename. init derives the extension from the format. |
Locally and in scheduled jobs, particles token-studio sync reads both fields and writes the file — no flags needed. In an event-driven webhook workflow, read the same file so the format and path stay defined in exactly one place (shown below).
Need more than one artifact (say a Tailwind CSS file and a typed TS theme)? Add a step per format with token-studio export --format <fmt> --output <path>, or pull each format URL from data.exports in the payload.
GitHub Actions example#
Point the webhook at a repository_dispatch relay (or any receiver that forwards the payload), then read particles-ui.json for the format and path, pull the release, and open a PR:
on:
repository_dispatch:
types: [particles-release] # release.published webhook
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Format + output path are the repo's choice — read them from particles-ui.json.
- id: cfg
run: |
echo "format=$(jq -r .format particles-ui.json)" >> "$GITHUB_OUTPUT"
echo "out=$(jq -r .outputDestination particles-ui.json)" >> "$GITHUB_OUTPUT"
- name: Pull released tokens
env:
WHSEC: ${{ secrets.PARTICLES_WEBHOOK_SECRET }}
PID: ${{ github.event.client_payload.projectId }}
VERSION: ${{ github.event.client_payload.data.version }}
run: |
mkdir -p "$(dirname "${{ steps.cfg.outputs.out }}")"
curl -fsS -H "Authorization: Bearer $WHSEC" \
"https://api.particles-ui.com/v1/projects/$PID/releases/$VERSION/export?format=${{ steps.cfg.outputs.format }}" \
| jq -r '.files[0].contents' > "${{ steps.cfg.outputs.out }}"
- uses: peter-evans/create-pull-request@v6
with:
branch: tokens/${{ github.event.client_payload.data.version }}
title: "chore(tokens): sync Particles release ${{ github.event.client_payload.data.version }}"
body: ${{ github.event.client_payload.data.notes }}The format and output path come from particles-ui.json, not the workflow — change them with particles init and every CI run follows. The webhook is what makes this event-driven: it runs the moment a release is published, not on a schedule. For local or scheduled pulls, particles token-studio sync --version <v> reads the same config — see CLI.
Triggering the workflow on publish#
The workflow above listens for repository_dispatch. GitHub only starts a workflow from an authenticated dispatch call, so point a Particles webhook directly at GitHub's dispatch endpoint — no relay or extra infrastructure:
| Webhook field | Value |
|---|---|
| URL | https://api.github.com/repos/<owner>/<repo>/dispatches |
| Secret | A GitHub token (PAT) with Contents: read & write on the repo |
| Event | release.published |
Create it in Studio → Settings → Webhooks. When the URL is a GitHub dispatch endpoint, Particles delivers the event in GitHub's shape — the secret is sent as the Bearer token and the payload is wrapped as { event_type: "particles-release", client_payload }, which fires the repository_dispatch workflow. Publishing a release now opens a token PR automatically.
The PAT is the webhook secret here, so scope it tightly (a fine-grained token limited to the one repo). In the repo, enable Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests so the workflow can open the PR.