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

# Find-or-create customer + enrolment (no transaction)

> Same identifier resolution + create-on-miss as
`/marketplace/accrue`, but without posting a transaction.

Used by checkout-time signup flows (Shopify customers/create,
Toast guest-create) where the partner wants the customer on
the loyalty programme but doesn't have a purchase to credit
yet. Idempotent on `(customer, card)` — repeating the call
returns the same enrolment at HTTP 200.

This endpoint changes live customer data and therefore rejects test
credentials with 403.




## OpenAPI

````yaml /api/openapi.yaml post /marketplace/enroll
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:
  /marketplace/enroll:
    post:
      tags:
        - Marketplace
      summary: Find-or-create customer + enrolment (no transaction)
      description: |
        Same identifier resolution + create-on-miss as
        `/marketplace/accrue`, but without posting a transaction.

        Used by checkout-time signup flows (Shopify customers/create,
        Toast guest-create) where the partner wants the customer on
        the loyalty programme but doesn't have a purchase to credit
        yet. Idempotent on `(customer, card)` — repeating the call
        returns the same enrolment at HTTP 200.

        This endpoint changes live customer data and therefore rejects test
        credentials with 403.
      operationId: marketplaceEnroll
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MarketplaceEnrollRequest'
      responses:
        '200':
          description: Existing enrolment returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketplaceEnrollResponse'
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketplaceEnrollResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '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:
    MarketplaceEnrollRequest:
      type: object
      required:
        - identifier
      properties:
        identifier:
          $ref: '#/components/schemas/MarketplaceIdentifier'
        card_id:
          type: string
        create_customer_if_missing:
          type: boolean
          default: true
        customer_fields:
          $ref: '#/components/schemas/MarketplaceCustomerFields'
        source:
          type: string
          maxLength: 60
          description: |
            Free-text marketing source ("shopify_checkout",
            "toast_guest_create"). Surfaced on the
            CUSTOMER_ENROLLED webhook payload.
    MarketplaceEnrollResponse:
      type: object
      properties:
        customer:
          $ref: '#/components/schemas/Customer'
        enrollment:
          $ref: '#/components/schemas/Enrollment'
        wallet_passes:
          $ref: '#/components/schemas/MarketplaceWalletPasses'
        customer_created:
          type: boolean
        enrollment_created:
          type: boolean
    MarketplaceIdentifier:
      type: object
      description: |
        Customer identifier block. At least ONE field must be present.
        Resolution order is fixed: card_serial → external_ref → email
        → phone. First match wins.
      properties:
        phone:
          type: string
        email:
          type: string
          format: email
        external_ref:
          type: string
          description: |
            Partner-supplied id (Shopify customer GID, Toast guest
            UUID). Resolves via the unique `(orgId, externalRef)`
            customer index.
        card_serial:
          type: string
          description: |
            Stable POS barcode or legacy wallet-pass serial scanned at
            the till. Resolves to the customer who owns the active pass.
    MarketplaceCustomerFields:
      type: object
      description: |
        Fields used when create_customer_if_missing is true and no
        existing customer matches the identifier.
      properties:
        name:
          type: string
          description: First-whitespace-split into first_name / last_name.
        first_name:
          type: string
        last_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
    Customer:
      type: object
      properties:
        id:
          type: string
        customer_number:
          type: integer
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
          nullable: true
        date_of_birth:
          type: string
          nullable: true
          format: date-time
        consent_marketing:
          type: boolean
        consent_ad_tracking:
          type: boolean
        external_ref:
          type: string
          nullable: true
          description: |
            Partner-supplied identifier (Shopify customer GID, Toast
            guest UUID, Square customer id, …). Unique per org. POST
            `/customers` upserts on this when supplied.
        anonymized_at:
          type: string
          nullable: true
          format: date-time
        deleted_at:
          type: string
          nullable: true
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    Enrollment:
      type: object
      properties:
        id:
          type: string
        customer_id:
          type: string
        card_id:
          type: string
        status:
          type: string
          enum:
            - ACTIVE
            - INACTIVE
            - REVOKED
        balance:
          type: number
          multipleOf: 0.01
        redemptions_count:
          type: integer
        cumulative_spend_pence:
          type: integer
        bonus_points_balance:
          type: integer
        enrolled_at:
          type: string
          format: date-time
        last_activity_at:
          type: string
          nullable: true
          format: date-time
        wallet_passes:
          nullable: true
          description: |
            Install URLs for the Apple / Google wallet passes
            attached to this enrollment. Null when no passes have
            been issued yet (poll `GET /enrollments/{id}` to pick
            them up later). Per-platform values may be null when
            that platform's credentials aren't configured for the
            org. Always reuse these URLs — never reconstruct them.
          type: object
          properties:
            apple:
              type: string
              nullable: true
              description: pkpass download URL.
            google:
              type: string
              nullable: true
              description: |
                Freshly-signed Add-to-Wallet save link
                (`https://pay.google.com/gp/v/save/<jwt>`).
    MarketplaceWalletPasses:
      type: object
      nullable: true
      properties:
        apple:
          type: string
          nullable: true
        google:
          type: string
          nullable: true
    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'
    NotFound:
      description: Resource not found
      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

- [Find-or-create customer + post transaction in one call](/api-reference/marketplace/find-or-create-customer-+-post-transaction-in-one-call.md)
- [Build a POS integration](/guides/pos.md)
- [Authentication and scopes](/fundamentals/authentication.md)
- [Test mode](/fundamentals/test-mode.md)
- [Create or upsert a customer](/api-reference/customers/create-or-upsert-a-customer.md)
