# Cards CLI commands

> Canonical HTML: https://screenrig.ai/docs/cli/cards

Find card types, create a card, set its parameters and live data, preview it and update its data from outside systems through signed webhooks. Every command here runs as described in the [CLI reference](https://screenrig.ai/docs/cli.md): global flags, JSON output, exit codes, revisions and retries.

## Cards

Discover card types, create an instance, and update its live data. A playlist places the returned card ID.

```console
screenrig cards list --category ops
screenrig cards show gauge
screenrig card create --type gauge --file gauge-params.json
screenrig card list
screenrig card show crd_01EXAMPLEGAUGE000000000
screenrig card params crd_01EXAMPLEGAUGE000000000 --file gauge-params.json
screenrig card data get crd_01EXAMPLEGAUGE000000000
screenrig card data put crd_01EXAMPLEGAUGE000000000 --file gauge-data.json --ttl 30m --expect-rev 1
screenrig card preview crd_01EXAMPLEGAUGE000000000 --output gauge.png
screenrig card delete crd_01EXAMPLEGAUGE000000000
```

| Parameter | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `cards list --tag TAG` | string | no | none | Match a catalogue tag. |
| `cards list --category CATEGORY` | string | no | none | Match a catalogue category. With `--tag`, a card must match both. |
| `cards show <id>` | string | yes | none | The catalogue card id from `cards list`, such as `gauge`. |
| `card create --type ID` | string | yes | none | The catalogue card id from `cards list`. |
| `card create --major N` | integer | no | current major | Pin a catalogue major version. |
| `card create --file FILE` | path | yes | none | The static params as a JSON object of at most 1 MiB, or `-` for stdin. |
| `<id>` | string | yes | none | The `crd_` card id taken by `card show`, `card params`, `card delete` and `card data`. |
| `card params --file FILE` | path | yes | none | The new static params, read as for `card create`. They replace the old ones at once. |
| `card data put --file FILE` | path | yes | none | The data document as a JSON object, or `-` for stdin. It replaces the whole document. |
| `card data put --ttl DURATION` | string | no | none | Blank the data after this long, offline screens included: `30m`, `12h` or `1d`, from `1s` to `365d`. Not with `--expires-at`. |
| `card data put --expires-at INSTANT` | string | no | none | Blank the data at this ISO 8601 timestamp, which carries a time zone. A write with neither option clears the old expiry. |
| `card data put --expect-rev REVISION` | string | no | none | Write only if the data is still at this revision, from `card data get`. A stale value is `revision_conflict` (exit 6). |
| `card preview <type\|id>` | string | yes | none | A catalogue card id, or a `crd_` card id to draw that instance with its own params and data. |
| `card preview --output FILE` | path | yes | none | Write the PNG here. Parent directories are created. |
| `card preview --params FILE` | path | no | none | Static properties as a JSON object. |
| `card preview --data FILE` | path | no | none | The dynamic document as a JSON object. |
| `card preview --feed FILE` | path | no | none | The host feed document as a JSON object. A feed the card needs and you leave out is reported. |
| `card preview --width PX` / `--height PX` | integer | no | `1920` / `1080` | Image size, 1 to 8192. |
| `card preview --layout LAYOUT` | string | no | `full` | `full`, `free`, `center`, `wide`, `header`, `footer`, `corner`, `chip` or `strip`: the layout to draw. |
| `card preview --anchor ANCHOR` | string | no | none | The corner of a `corner` or `chip` layout, or the edge of a `strip`. |
| `card preview --now ISO` | string | no | the current time | Freeze the clock at this ISO 8601 instant. |
| `card preview --timezone ZONE` | string | no | this machine's | Draw in this IANA time zone, such as `America/Vancouver`. |

`cards show` prints the card's `params_schema` and `data_schema`, its static and dynamic properties, tier, layouts and feed, and an example to copy into a playlist page. `card create` returns the instance's `crd_` id, and an identical rerun after a lost answer reuses the saved idempotency key.

`card data put` replaces the document at once, without republishing the playlist. The card's own validation refuses a document it cannot show ([`card_data_invalid`](/problems/card-data-invalid)), and an expiry blanks the data on every screen when nothing renews it. `card delete` removes the instance and its data, and answers `resource_conflict` (exit 5) while a playlist places it.

`card preview` draws the card in a local Chromium or Chrome and writes a PNG; it publishes nothing. Set `SCREENRIG_BROWSER` to the browser's path when none of the usual ones is installed. `data.file` is the path written and `data.unrendered` lists what the preview could not draw, such as a host feed you did not supply. Open the image before you publish. [Built-in cards](https://screenrig.ai/docs/cards.md) lists each card's layouts, properties and feeds.

## Card webhooks

A card webhook is a URL that writes the data document of one card, so an outside system such as a CMS, a point of sale or a script pushes data to a card with one HTTP POST. `kv webhook create`, `list`, `rotate-secret` and `delete` take `--card CARD_ID` in place of `--app-id`, never both; `update` and `deliveries` below work on cards only. A card has at most 20 webhooks.

```console
kv webhook create --card CARD_ID [--description TEXT] [--map FILE|-] [--key-template TEMPLATE] [--signing]
                  [--verify VENDOR [--verify-secret-file FILE|-] [--verify-header NAME] [--verify-encoding hex|base64]
                   [--verify-timestamp-header NAME] [--verify-tolerance SECONDS]] [--handshake VENDOR]
kv webhook list --card CARD_ID
kv webhook rotate-secret <webhook_id> --card CARD_ID [--signing]
kv webhook delete <webhook_id> --card CARD_ID
```

Creating a card webhook takes no key. `--map FILE` (or `-` for stdin) is the mapping, `--key-template` writes keyed documents, `--signing` turns signing on, and `--verify` and `--handshake` accept a vendor's own posts ([Vendor signatures and handshakes](https://screenrig.ai/docs/cli/cards.md#vendor-signatures-and-handshakes)). The answer carries `data.secret` and, when the webhook signs, `data.signing_secret`, each shown once.

The sender authenticates as for an application webhook: `Authorization: Bearer <secret>` with the `srkw_…` secret, or HTTP Basic with the webhook id as the username, so `https://kvw_ID:srkw_…@api.screenrig.ai/…` suits a sender that takes only a URL. The body, at most 1 MiB, is JSON or `application/x-www-form-urlencoded`; a repeated form name becomes an array. Without a mapping the posted object is the card's data document.

A mapping is a JSON template that builds the document from the body. A string with placeholders such as `{{body.alert.title}}` or `{{body.items[0].name}}` is text, and a string that is exactly one placeholder keeps the value's type. A path starts with `body` or `query` (the URL's query parameters), and a path that holds nothing is empty in text and leaves a lone value out. `$each` maps an array with `item` and `index` in scope, `$group` with `$by` groups in first-seen order, `$number` reads `1,234.50`, `$3.50`, `3,50 €` or `12%` (add `"$decimal": ","` for a comma mark), `$bool` reads true, yes, y, 1 and x, and `$text` with `$max` cuts text. The template is checked when saved: `invalid_request` names the place that failed.

```console
{
  "title": "{{body.alert.title}}",
  "body": "{{body.alert.message}}",
  "items": { "$each": "{{body.items}}", "$limit": 20, "$template": { "text": "{{item.name}}" } }
}
```

```console
curl https://api.screenrig.ai/api/cards/crd_01EXAMPLEBOARD0000000000/kv-webhooks/kvw_01EXAMPLEWEBHOOK00000/write \
  -H "Authorization: Bearer $CARD_WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  --data '{"alert":{"title":"Fire drill","message":"Leave by the east door"},"items":[{"name":"Floor 2"}]}'
```

`--key-template TEMPLATE` writes keyed documents in place of the card's own document. The template is a card data key with `{screen.field.NAME}` tokens, such as `room-{screen.field.room}`, and the mapped object (or the posted body, without a mapping) holds only a `documents` array of `{fields, document}` objects: each `fields` object fills the template and each `document` is written under the key it makes. A page item that gives the same template as its `data` shows each screen the document of its own fields. Every document is checked before any is written, and a repeated key is refused. A storage failure can leave part of a batch written: retry the identical body with the same `Idempotency-Key`. Keys a post omits keep their data, and a webhook sets no expiry. [Keyed documents](https://screenrig.ai/docs/sources.md#keyed-documents) shows a mapping.

Every post is checked as the card's own data: [`card_data_invalid`](/problems/card-data-invalid) names the path and nothing is written. A document equal to the card's current one answers `unchanged` and writes nothing, so no screen is woken. An accepted post does not count toward the agent request cap, a webhook takes at most 120 posts a minute, and an `Idempotency-Key` header deduplicates a sender's retries for 24 hours.

A wrong address, a wrong or missing secret, a disabled webhook, a missing or wrong signature and every `GET` answer the same `404`, so a link preview or a guess learns nothing. `kv webhook deliveries` says why.

With `--signing`, every post must also carry `ScreenRig-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `t`, a dot and the raw body under the `whsec_…` signing secret, and `t` must be within five minutes of the server's clock. It is the format of screenRIG's own webhook signatures.

### Vendor signatures and handshakes

Vendors sign their posts their own way, and some cannot send your secret at all. A card webhook can verify the vendor's signature in place of the secret and answer the vendor's setup handshake, so GitHub, Stripe, Shopify, Slack, Linear, Vercel, Asana, Microsoft Graph, monday.com, Trello and Azure Event Grid post straight to it with no relay in between. Any other sender that signs with HMAC works too. Set them with `--verify` and `--handshake` on `kv webhook create`, and change them with `kv webhook update`.

- `--verify VENDOR` accepts a vendor's own signature, with `--verify-secret-file FILE` (or `-` for stdin, never an argument) holding the secret the vendor shows. For `generic_hmac`, `--verify-header NAME` names the header that carries the signature, `--verify-encoding` is `hex` (the default) or `base64`, `--verify-timestamp-header NAME` names a header that carries the Unix time, and `--verify-tolerance SECONDS` is how far it may sit from the server's clock (300 by default). A post that carries the vendor's valid signature is accepted without the webhook's own `srkw_` secret, which keeps working beside it. The vendor secret is stored sealed and shown only as `set` and a `hint` of its last four characters. On `kv webhook update`, `--verify` without `--verify-secret-file` keeps the stored secret while the vendor stays the same, and `--clear-verify` removes the verification and its secret. `--verify` and `--signing` exclude each other.
- `--handshake VENDOR` is `microsoft_graph`, `monday`, `trello` or `azure_event_grid`, and `--clear-handshake` removes it. Slack and Asana answer their own handshakes through `--verify`.

| `--verify` | Header | What is signed | Timestamp |
| --- | --- | --- | --- |
| `github` | `X-Hub-Signature-256` | HMAC-SHA256 of the body | None |
| `stripe` | `Stripe-Signature` | HMAC-SHA256 of the timestamp, a dot and the body; any `v1` may match | 5 minutes |
| `shopify` | `X-Shopify-Hmac-Sha256` | HMAC-SHA256 of the body, in base64 | None |
| `slack` | `X-Slack-Signature` | HMAC-SHA256 of `v0:`, the timestamp, a colon and the body | `X-Slack-Request-Timestamp`, 5 minutes |
| `linear` | `Linear-Signature` | HMAC-SHA256 of the body | `webhookTimestamp` in the body, 1 minute |
| `vercel` | `x-vercel-signature` | HMAC-SHA1 of the body | None |
| `asana` | `X-Hook-Signature` | HMAC-SHA256 of the body, keyed by the handshake's `X-Hook-Secret` | None |
| `generic_hmac` | The `--verify-header` you name | HMAC-SHA256 of the body, or with a `--verify-timestamp-header` of the timestamp, a dot and the body | `--verify-tolerance` |

The key of every HMAC is the secret's text as the vendor shows it (Stripe's includes `whsec_`). A signature is checked on the body exactly as it arrived, in constant time, before the post takes any write budget, and it is judged before its timestamp: `timestamp_stale` always means the secret was right. A body may be JSON, a form or `multipart/form-data`, where a text field is `body.FIELD` and files are counted, not read.

| Vendor | screenRIG answers | Later posts |
| --- | --- | --- |
| Slack (`--verify slack`) | The signed `url_verification` with its `challenge`; a person testing with `curl` can send the webhook's secret instead | Signed with the signing secret |
| Asana (`--verify asana`) | The handshake's `X-Hook-Secret`, which it keeps sealed as the key of every `X-Hook-Signature`. The same secret again is a retry; a different one is refused as `handshake_invalid` | `X-Hook-Signature` |
| Microsoft Graph (`--handshake microsoft_graph`) | The decoded `?validationToken=` as `text/plain` | Carry the webhook's secret |
| monday.com (`--handshake monday`) | The `challenge` it posts, echoed as JSON | Carry the webhook's secret |
| Trello (`--handshake trello`) | A `HEAD` check answers 200 with no body, for this webhook only | Carry the webhook's secret |
| Azure Event Grid (`--handshake azure_event_grid`) | `{"validationResponse": "CODE"}` | Carry the webhook's secret |

A handshake is answered only for a webhook set up for that vendor, before any credential is asked for, to a request that is that handshake, and never by a disabled webhook. It writes nothing, is never billed and spends no rate budget. Microsoft Graph, monday.com, Trello and Azure Event Grid send no header screenRIG verifies, so their deliveries are accepted only with the webhook's secret. Azure Event Grid takes it as a static delivery header, `Authorization: Bearer srkw_…`, marked secret, and its events arrive as an array, so a mapping reads `body[0]`.

| Vendor | In the vendor's settings |
| --- | --- |
| GitHub | Settings, Webhooks, Add webhook: Payload URL is the webhook's `url`, content type `application/json`, and the same secret. GitHub's `ping` is signed and accepted |
| Stripe | Workbench, Webhooks: add the `url` and reveal its signing secret. While Stripe rolls a secret it sends a `v1` for each and either is accepted; replace the stored secret afterwards with `kv webhook update --verify stripe --verify-secret-file FILE` |
| Shopify | Subscribe with the `url` in JSON format. The secret is the app's client secret |
| Slack | The Events API Request URL is the `url`, and the secret is the app's signing secret. A slash command posts a form: `{{body.text}}`, `{{body.user_name}}` |
| Linear | In the API settings create a webhook with the `url` and copy its signing secret. The body's `webhookTimestamp` must be within a minute of the server's clock |
| Vercel | Team settings, Webhooks: create one with the `url`. Vercel shows the secret once |
| Asana | Create the webhook with `--verify asana`: it has no secret yet and refuses events as `secret_pending`. Then create the Asana webhook with `target` set to the `url`, and the handshake sets the secret. To take a new secret, run `kv webhook update` with `--clear-verify`, then again with `--verify asana` |
| Microsoft Graph | Create the subscription with `notificationUrl` set to the `url` |
| monday.com | Run `create_webhook` with the `url` |
| Trello | `POST /1/webhooks` with `callbackURL` set to the `url` |
| Azure Event Grid | Create the event subscription with the `url` as its webhook endpoint, and add the delivery header above |

```console
echo '{"title":"{{body.repository.full_name}}","body":"{{body.head_commit.message}}"}' |
  screenrig kv webhook create --card crd_01EXAMPLEBOARD0000000000 --description "Pushes" \
    --verify github --verify-secret-file github.secret --map -
```

A sender that signs with its own HMAC names the header, the encoding and, if it signs a timestamp, the timestamp header:

```console
echo '{"title":"{{body.sensor}}","body":"{{body.state}}"}' |
  screenrig kv webhook create --card crd_01EXAMPLEBOARD0000000000 --verify generic_hmac --verify-secret-file acme.secret \
    --verify-header X-Acme-Signature --verify-encoding base64 --verify-timestamp-header X-Acme-Time --verify-tolerance 120 \
    --map -
```

```console
ts=$(date +%s); body='{"sensor":"door","state":"open"}'
sig=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A)
curl "$URL" -H "Content-Type: application/json" -H "X-Acme-Time: $ts" -H "X-Acme-Signature: $sig" -d "$body"
```

Every failure is the same `404` as any other refusal. `kv webhook deliveries` shows the reason, and a handshake shows as outcome `handshake`:

| Reason | Meaning |
| --- | --- |
| `signature_missing` | A vendor verification is set and the post carried neither the secret nor the vendor's header |
| `signature_invalid` | The header is malformed or does not match the body |
| `timestamp_missing` | The signature needs a timestamp the post did not carry |
| `timestamp_stale` | The signature is valid and its timestamp is outside the window |
| `secret_pending` | An Asana verification is still waiting for its handshake |
| `handshake_invalid` | The request is a vendor's handshake and cannot be answered |

### `kv webhook update`

Change a card webhook: its description, mapping, keyed documents, enabled state, signing, vendor verification or handshake.

```console
kv webhook update <webhook_id> --card CARD_ID [--description TEXT] [--map FILE|-] [--clear-map]
                   [--key-template TEMPLATE] [--clear-key-template] [--enable|--disable] [--signing|--no-signing]
                   [--verify VENDOR [--verify-secret-file FILE|-] [--verify-header NAME] [--verify-encoding hex|base64]
                    [--verify-timestamp-header NAME] [--verify-tolerance SECONDS]] [--clear-verify]
                   [--handshake VENDOR] [--clear-handshake]
```

| Parameter | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `<webhook_id>` | string | yes | none | The `kvw_` webhook id. |
| `--card CARD_ID` | string | yes | none | The `crd_` card id. |
| `--description TEXT` | string | no | none | What sends to it, at most 200 bytes. It must not hold a credential. |
| `--map FILE` | path | no | none | Replace the mapping with a JSON object read from a file, or `-` for stdin. Not with `--clear-map`. |
| `--clear-map` | boolean | no | off | Remove the mapping: the posted object becomes the document. |
| `--key-template TEMPLATE` | string | no | none | Set the key template that keys the documents, such as `room-{screen.field.room}`. Not with `--clear-key-template`. |
| `--clear-key-template` | boolean | no | off | Return to writing the card's own document. |
| `--enable` / `--disable` | boolean | no | off | `--disable` answers every post like a wrong secret until `--enable`. |
| `--signing` / `--no-signing` | boolean | no | off | `--signing` requires the signature header and prints the signing secret once; a webhook that already signs keeps its secret, which `kv webhook rotate-secret --signing` replaces. `--no-signing` stops requiring it. Not with `--verify`. |
| `--verify VENDOR` | string | no | none | Replace the vendor verification: `github`, `stripe`, `shopify`, `slack`, `linear`, `vercel`, `asana` or `generic_hmac`. Without `--verify-secret-file` the stored secret is kept when the vendor is the same, so a header or a window can change alone; a different vendor needs its own. Not with `--clear-verify`. |
| `--verify-secret-file FILE` | path | no | the stored secret | Read the vendor's secret from a file, or `-` for stdin (never an argument). `asana` takes none. Only one option reads stdin. |
| `--verify-header NAME` | string | with `generic_hmac` | none | The header that carries the signature, 1 to 100 letters, digits, `-` or `_`. |
| `--verify-encoding ENCODING` | string | no | `hex` | With `generic_hmac`: `hex` or `base64`; it may begin with `sha256=`. |
| `--verify-timestamp-header NAME` | string | no | none | With `generic_hmac`: a header that carries the Unix time in seconds, or in milliseconds as 13 digits. |
| `--verify-tolerance SECONDS` | integer | no | `300` | With `--verify-timestamp-header`: how far the timestamp may sit from the server's clock, 1 to 86400. |
| `--clear-verify` | boolean | no | off | Stop accepting the vendor's signature and forget its secret. An Asana verification takes a new handshake only after `--clear-verify` and `--verify asana` again. |
| `--handshake VENDOR` | string | no | none | Set the handshake the URL answers: `microsoft_graph`, `monday`, `trello` or `azure_event_grid`. Not with `--clear-handshake`. |
| `--clear-handshake` | boolean | no | off | Stop answering the vendor handshake. |

At least one change is required.

```console
screenrig kv webhook update kvw_01EXAMPLEWEBHOOK00000 --card crd_01EXAMPLEBOARD0000000000 --map alert-map.json
screenrig kv webhook update kvw_01EXAMPLEWEBHOOK00000 --card crd_01EXAMPLEBOARD0000000000 --verify stripe --verify-secret-file stripe.secret
screenrig kv webhook update kvw_01EXAMPLEWEBHOOK00000 --card crd_01EXAMPLEBOARD0000000000 --clear-verify
```

The envelope is the webhook. `data.signing_secret` appears once, in the answer that turned signing on. After an interrupted or ambiguous update, rerun the identical command: it reuses the saved idempotency key.

### `kv webhook deliveries`

Read a card webhook's delivery log, newest first, for the last seven days.

```console
kv webhook deliveries <webhook_id> --card CARD_ID [--limit N]
```

| Parameter | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `<webhook_id>` | string | yes | none | The `kvw_` webhook id. |
| `--card CARD_ID` | string | yes | none | The `crd_` card id. |
| `--limit N` | number | no | 100 | How many deliveries to return, 1 to 100. |

```console
screenrig kv webhook deliveries kvw_01EXAMPLEWEBHOOK00000 --card crd_01EXAMPLEBOARD0000000000
```

Each item has `at`, `outcome` (`written`, `unchanged`, `refused`, `invalid` or `handshake`), `reason` (`credentials`, `signature` for a failed `ScreenRig-Signature`, `disabled`, `body`, `mapping`, `card_data_invalid`, `rate_limited`, `internal`, or a vendor reason from the table above), `detail`, `status`, `bytes`, `files` (the file parts of a multipart post, which are ignored), `revision` and `request_id`. No body, header or credential is stored, and a post to a wrong address leaves no row.

## Other command groups

- [Projects](https://screenrig.ai/docs/cli/projects.md)
- [Service clients](https://screenrig.ai/docs/cli/service-clients.md)
- [Help and support](https://screenrig.ai/docs/cli/help.md)
- [Media](https://screenrig.ai/docs/cli/media.md)
- [Media sync](https://screenrig.ai/docs/cli/media-sync.md)
- [Playlists](https://screenrig.ai/docs/cli/playlists.md)
- [Applications](https://screenrig.ai/docs/cli/applications.md)
- [Screens](https://screenrig.ai/docs/cli/screens.md)
- [Schedules](https://screenrig.ai/docs/cli/schedules.md)
- [Campaigns](https://screenrig.ai/docs/cli/campaigns.md)
- [Device health](https://screenrig.ai/docs/cli/device.md)
- [Reviews and notes](https://screenrig.ai/docs/cli/reviews.md)
- [Notifications](https://screenrig.ai/docs/cli/notifications.md)
- [Reports](https://screenrig.ai/docs/cli/reports.md)
- [Data sources](https://screenrig.ai/docs/cli/data-sources.md)
- [BI dashboards](https://screenrig.ai/docs/cli/bi.md)
- [Triggers](https://screenrig.ai/docs/cli/triggers.md)
- [Fleet links](https://screenrig.ai/docs/cli/fleet-links.md)
- [Advertising](https://screenrig.ai/docs/cli/ads.md)
