> ## Documentation Index
> Fetch the complete documentation index at: https://developers.perkstar.co.uk/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a webhook

> Direct API-key requests use the normal 24-hour idempotency response
cache. For OAuth REST-hook clients, the idempotency key remains bound
to the live subscription until DELETE. A retry with the same event set
returns that row; use PATCH to apply a current URL, card, mode, or active
state. `client_hmac` subscriptions return their signing secret on create
and OAuth idempotent replay. OAuth-only `server_attested` subscriptions
keep the signing secret inside Perkstar and omit it from every response.
Reusing the key for a different event set or verification mode returns
an idempotency conflict.




## OpenAPI

````yaml /api/openapi.yaml post /webhooks
openapi: 3.1.0
info:
  title: Perkstar Public REST API
  version: 2026-05-04.oauth
  description: |
    Programmatic access to your Perkstar loyalty data. Two authentication
    modes are supported:

    - **API key.** Per-org bearer token issued from Settings → API keys
      (`pk_live_…` / `pk_test_…`). Right choice for direct integrations,
      single-tenant POS adapters, and back-office sync.
    - **OAuth 2.0.** Three-legged authorization-code flow with PKCE,
      for marketplace listings (Square App Marketplace, Shopify App
      Store, Toast Partner Marketplace, Lightspeed). The merchant
      installs your app from the partner marketplace, lands on
      `/oauth/authorize`, approves the requested scopes, and your
      server exchanges the code at `/api/oauth/token` for a short-lived
      access token. See the dedicated OAuth section below.

    Every endpoint is scoped, rate-limited, and idempotent on POST.
    POS-adapter builders should start with the `/marketplace/*`
    endpoints — they collapse the typical "find-or-create customer +
    enrolment + post transaction" flow into a single round-trip with
    permanent dedupe via `external_transaction_id`.

    ## Test credentials

    Test credentials (`pk_test_…` or OAuth applications in TEST mode)
    can read API data, create isolated test transactions, and simulate
    wallet pushes. They cannot create, update, or delete live customers,
    enrolments, or webhook configuration. Test calls to
    `/marketplace/accrue` only work with an existing customer and
    enrolment; `/marketplace/enroll` is live-only. This prevents a
    sandbox integration from changing live customer records.

    Copy-paste cURL recipes are published at `/api/v1/curl-recipes.md`
    for teams that want to test the API before wiring the SDK.

    ## Response headers (universal)

    Every response, success or error, carries:

    - **X-Request-Id** — unique per request. Echo this in support
      tickets; we can find the exact call in the per-key audit log.
    - **X-API-Version** — date-versioned schema marker
      (`2026-05-04` as of writing). 12-month deprecation policy.
    - **X-RateLimit-Limit** — per-minute quota for the credential.
    - **X-RateLimit-Remaining** — tokens left in the current window.
    - **X-RateLimit-Reset** — unix-seconds when the window refills.
    - **Retry-After** — emitted on 429 only; seconds to wait.

    The reusable error responses below (`Unauthorized`, `Forbidden`,
    `NotFound`, `RateLimited`, `ValidationError`) declare these as
    strongly-typed `headers` so codegen against an error path has
    them. Success responses inline their `"200"` / `"201"` and rely
    on this convention rather than per-endpoint header declarations.
  contact:
    name: Perkstar
    url: https://perkstar.co.uk
servers:
  - url: https://dashboard.perkstar.co.uk/api/v1
    description: Production
security:
  - bearerAuth: []
paths:
  /webhooks:
    post:
      tags:
        - Webhooks
      summary: Create a webhook
      description: >
        Direct API-key requests use the normal 24-hour idempotency response

        cache. For OAuth REST-hook clients, the idempotency key remains bound

        to the live subscription until DELETE. A retry with the same event set

        returns that row; use PATCH to apply a current URL, card, mode, or
        active

        state. `client_hmac` subscriptions return their signing secret on create

        and OAuth idempotent replay. OAuth-only `server_attested` subscriptions

        keep the signing secret inside Perkstar and omit it from every response.

        Reusing the key for a different event set or verification mode returns

        an idempotency conflict.
      operationId: createWebhook
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCreate'
      responses:
        '200':
          description: >-
            OAuth idempotent replay of the same live subscription. `secret` is
            present only for `client_hmac`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Webhook'
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: >-
                          Present only for `client_hmac`; never returned for
                          `server_attested`.
        '201':
          description: >-
            Created. Capture `secret` for `client_hmac`; `server_attested`
            deliberately omits it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Webhook'
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: >-
                          Present only for `client_hmac`; never returned for
                          `server_attested`.
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
      security:
        - bearerAuth: []
components:
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      schema:
        type: string
        maxLength: 255
      description: |
        Replay-safe request key. Repeated requests with the same value
        within 24h return the original response unchanged.
  schemas:
    WebhookCreate:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          description: Empty means subscribe to every event.
          items:
            $ref: '#/components/schemas/WebhookEventName'
        card_id:
          type: string
          nullable: true
          description: >-
            Optional card in the same business. Events without this card ID are
            not delivered.
        mode:
          allOf:
            - $ref: '#/components/schemas/WebhookEventMode'
          default: all
        verification_mode:
          allOf:
            - $ref: '#/components/schemas/WebhookVerificationMode'
          default: client_hmac
          description: |
            `server_attested` is OAuth-only and never releases the signing
            secret. The mode is immutable after creation.
        description:
          type: string
          nullable: true
        is_active:
          type: boolean
          default: true
          description: Create the webhook paused when false; activate it later with PATCH.
    Webhook:
      type: object
      properties:
        id:
          type: string
        url:
          type: string
        description:
          type: string
          nullable: true
        events:
          type: array
          description: Empty means subscribe to every event.
          items:
            $ref: '#/components/schemas/WebhookEventName'
        card_id:
          type: string
          nullable: true
          description: >-
            Optional card filter applied before a delivery is created. Null
            receives matching events from every card.
        mode:
          $ref: '#/components/schemas/WebhookEventMode'
        verification_mode:
          $ref: '#/components/schemas/WebhookVerificationMode'
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WebhookEventName:
      type: string
      description: Stable dotted wire-format name for an outbound event.
      enum:
        - customer.enrolled
        - customer.unenrolled
        - customer.anonymized
        - customer.group_changed
        - wallet.installed
        - card.scanned
        - card.expired
        - referral.created
        - transaction.created
        - coupon.redeemed
        - reward.redeemed
        - tier.changed
        - ticket.purchased
        - ticket.cancelled
        - ticket.refunded
        - gift.purchased
        - gift.redeemed
        - multipass.purchased
        - membership.purchased
        - membership.renewed
        - membership.cancelled
        - feedback.submitted
        - automation.fired
        - broadcast.sent
        - booking.created
        - booking.confirmed
        - booking.attended
        - booking.no_show
        - booking.cancelled
        - webhook.test
    WebhookEventMode:
      type: string
      enum:
        - all
        - live
        - test
      description: >-
        Filter delivery by live/test activity before creating an outbound
        attempt. `all` preserves the default generic-webhook behaviour.
    WebhookVerificationMode:
      type: string
      enum:
        - client_hmac
        - server_attested
      description: |
        Immutable authenticity contract. `client_hmac` exposes the signing
        secret once so the receiver verifies the exact raw body.
        OAuth-only `server_attested` keeps that secret inside Perkstar and
        verifies the parsed event through the delivery-verification endpoint.
    Error:
      type: object
      properties:
        error:
          type: object
          required:
            - type
            - message
            - code
          properties:
            type:
              type: string
              enum:
                - authentication_error
                - permission_error
                - rate_limit_error
                - validation_error
                - not_found
                - idempotency_error
                - server_error
            message:
              type: string
            code:
              type: string
            param:
              type: string
  responses:
    ValidationError:
      description: Validation failed
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing / invalid / expired API key
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: API key is missing the required scope
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Per-key rate limit exceeded
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  headers:
    XRequestId:
      description: |
        Unique id for this request. Echo this in your support tickets
        and we can trace the exact call in the audit log on the API key
        detail page.
      schema:
        type: string
    XAPIVersion:
      description: |
        Date-versioned schema marker. The current value is `2026-05-04`.
        Bumps follow the 12-month deprecation policy documented in the
        in-dashboard docs page.
      schema:
        type: string
    XRateLimitLimit:
      description: Total requests permitted per minute for this credential.
      schema:
        type: integer
    XRateLimitRemaining:
      description: Requests left in the current 60s window.
      schema:
        type: integer
    XRateLimitReset:
      description: Unix-seconds timestamp when the current window refills.
      schema:
        type: integer
    RetryAfter:
      description: |
        Seconds to wait before retrying. Only emitted on 429 responses.
      schema:
        type: integer
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: pk_live_… / pk_test_… or perk_at_…

````

## Related topics

- [Receive webhooks](/guides/webhooks.md)
- [Webhooks and instant scenarios](/integrations/make/webhooks.md)
- [Webhook event catalogue](/reference/webhook-events.md)
- [Instant triggers and webhooks](/integrations/zapier/webhooks.md)
- [Create an enrollment](/api-reference/enrollments/create-an-enrollment.md)
