openapi: 3.0.3
info:
  title: eCardWidget Developer API
  version: "2.0.0"
  description: |
    Build with eCardWidget: **send eCards** programmatically and **keep your team-member directory
    in sync** from your own systems (HR/HRIS, CRM, donation platforms, internal tools). These are
    the same endpoints that power the official **Zapier** integration — so you can build a direct
    integration or use Zapier, whichever fits.

    # Quick start

    1. Grab your **API key** from the dashboard → **Settings → Developers → API Keys**.
    2. Send every request with it as a Bearer token:
       ```
       Authorization: Bearer YOUR_API_KEY
       ```
    3. Find a widget + eCard to send (`GET /pub/ecard-actions/get-ecards`), then send it
       (`POST /pub/ecard-actions/send-ecard`).

    ```bash
    # List the eCards in a widget
    curl "https://app.ecardwidget.com/v2/api/pub/ecard-actions/get-ecards?widgetId=WIDGET_ID" \
      -H "Authorization: Bearer $ECW_API_KEY"
    ```

    # Authentication

    All endpoints authenticate with your account **API key** as a Bearer token. Keys are shown in
    full only once at creation (store it safely), are labeled so you can tell integrations apart,
    and can be revoked anytime. **Every request is scoped to the account that owns the key** — you
    can only read or modify your own widgets, eCards, and directory (full tenant isolation).

    # Conventions

    - **Base URL:** `https://app.ecardwidget.com/v2/api`
    - **IDs are "vanity IDs"** — short opaque strings like `e4xgrzltk` (eCard), `w6v62rn95yq`
      (widget), `s2k7zqpvnll272r` (a sent/shared card). Treat them as opaque; don't parse them.
    - **Dates** accept common formats (`YYYY-MM-DD`, `M/D/YYYY`, ISO 8601). Birthdays are stored
      year-masked for privacy.

    # Response shapes

    Most endpoints return the **standard envelope**:
    ```json
    { "success": true, "data": { }, "messages": [] }
    ```
    - `success` — boolean.
    - `data` — object or array (endpoint-specific). List endpoints add a `meta` object (pagination).
    - `messages` — human-readable strings (errors/info).

    > ⚠️ **One exception:** `POST /pub/ecard-actions/send-ecard` returns a **flat** object (result
    > fields at the top level, no `data` wrapper) so integrators can map fields directly.

    # Errors

    Standard HTTP status codes; failures include `success: false` and a `messages` array.

    | Status | Meaning |
    |---|---|
    | `400` | Bad request — missing/invalid parameter |
    | `401` | Missing or invalid API key |
    | `403` | Authenticated but not allowed for this account |
    | `404` | Not found, or not owned by your account |
    | `422` | Validation failed (e.g. required custom fields) |
    | `429` | Rate limited — back off and retry |

    # Rate limits

    Keep request volume reasonable; excess returns `429`. For a first-time bulk load of an entire
    employee base, use the in-app **spreadsheet importer**, then use this API for ongoing deltas.

  contact:
    name: eCardWidget Support
    url: https://app.ecardwidget.com
    email: support@ecardwidget.com

servers:
  - url: https://app.ecardwidget.com/v2/api
    description: Production

security:
  - bearerAuth: []

tags:
  - name: eCards
    description: |
      List the eCards available in a widget, then send one by **email**, **schedule** it for a
      future time, or generate a **shareable link** (no email).
  - name: eCard Search (Enterprise)
    description: |
      Search/page your eCard designs. **Enterprise-only and actively evolving.** Unlike the public
      Bearer endpoints, this one authenticates with your **dashboard session** (`cfid`/`cftoken`),
      not the API key. For pure server-to-server eCard listing, use `GET /pub/ecard-actions/get-ecards`.
  - name: Directory Sync
    description: |
      Create, update, find, list, deactivate, and permanently delete team-member (directory)
      records — the people your automated cards and campaigns target. Designed for streaming
      one-record-at-a-time syncs from HR/CRM systems (PATCH semantics, stable-ID matching).
  - name: Field discovery
    description: Discover the fields and departments you can map when syncing members.
  - name: Webhooks
    description: |
      Subscribe to directory changes (member created / updated / deactivated / deleted) to push
      updates back into your own systems. Deliveries are signed and retried.

paths:

  # ───────────────────────────── eCards ─────────────────────────────
  /pub/ecard-actions/get-ecards:
    get:
      tags: [eCards]
      summary: List eCards in a widget
      operationId: getEcards
      description: |
        Returns the enabled eCards in a widget, with their names, HTML bodies, and image URLs.
        Use the returned `ecardid` (and the `widgetId` you passed) with **Send eCard**.
      parameters:
        - name: widgetId
          in: query
          required: true
          description: The widget's vanity ID (e.g. `w6v62rn95yq`).
          schema: { type: string }
          example: w6v62rn95yq
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.ecardwidget.com/v2/api/pub/ecard-actions/get-ecards?widgetId=w6v62rn95yq" \
              -H "Authorization: Bearer $ECW_API_KEY"
      responses:
        "200":
          description: eCards found
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EcardListItem" }
                  messages: { type: array, items: { type: string } }
              example:
                success: true
                data:
                  - widgetid: w6v62rn95yq
                    ecardid: e4xgrzltk
                    ecard_name: Happy Birthday
                    ecard_details: "<div>…</div>"
                    cover_image: "https://…/cover.png"
                    cover_thumb_image: "https://…/thumb.png"
                    envelope_bg_image_url: ""
                messages: []
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /pub/ecard-actions/send-ecard:
    post:
      tags: [eCards]
      summary: Send, schedule, or share an eCard
      operationId: sendEcard
      description: |
        Sends an eCard one of three ways:

        - **Email now** — the default. Emails the eCard to `recipientEmail`.
        - **Schedule** — set a *future* `sendAt`; it sends automatically at that time.
        - **Share link** — set `type` to `ecard_copy_link` (or `is_sharing: true`). Instead of
          emailing, it creates a viewable eCard and returns a **`shareUrl`** you can route anywhere
          (Slack, SMS, a CRM note). No email is sent, so `recipientEmail` is optional in this mode.

        **Required:** `ecardId`, `senderEmail`, `senderName`, `recipientName`. `recipientEmail` is
        required for email sends and optional for share links.

        > This endpoint returns a **flat** object (no `data` envelope). Email sends include
        > `emailsSent`; share sends include `statVanityId` + `shareUrl`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendEcardRequest" }
            examples:
              email:
                summary: Email an eCard now
                value:
                  ecardId: e4xgrzltk
                  senderEmail: development@acme.org
                  senderName: Acme Foundation
                  recipientName: Jordan
                  recipientEmail: jordan@example.com
                  personalMessage: "Thank you for your support!"
              scheduled:
                summary: Schedule for a future date
                value:
                  ecardId: e4xgrzltk
                  senderEmail: development@acme.org
                  senderName: Acme Foundation
                  recipientName: Jordan
                  recipientEmail: jordan@example.com
                  sendAt: "2026-12-24T09:00:00Z"
              shareLink:
                summary: Generate a share link (no email)
                value:
                  ecardId: e4xgrzltk
                  type: ecard_copy_link
                  senderEmail: development@acme.org
                  senderName: Acme Foundation
                  recipientName: Jordan
                  personalMessage: "Thanks!"
              mergeTags:
                summary: Fill custom fields / merge tags
                value:
                  ecardId: e4xgrzltk
                  senderEmail: development@acme.org
                  senderName: Acme Foundation
                  recipientName: Jordan
                  recipientEmail: jordan@example.com
                  mergeTags:
                    org_name: "Acme Corp"
                    match_amount: "$500"
      x-codeSamples:
        - lang: cURL (email)
          source: |
            curl -X POST https://app.ecardwidget.com/v2/api/pub/ecard-actions/send-ecard \
              -H "Authorization: Bearer $ECW_API_KEY" -H "Content-Type: application/json" \
              -d '{
                "ecardId": "e4xgrzltk",
                "senderEmail": "development@acme.org", "senderName": "Acme Foundation",
                "recipientName": "Jordan", "recipientEmail": "jordan@example.com",
                "personalMessage": "Thank you for your support!"
              }'
        - lang: cURL (share link)
          source: |
            curl -X POST https://app.ecardwidget.com/v2/api/pub/ecard-actions/send-ecard \
              -H "Authorization: Bearer $ECW_API_KEY" -H "Content-Type: application/json" \
              -d '{
                "ecardId": "e4xgrzltk", "type": "ecard_copy_link",
                "senderEmail": "development@acme.org", "senderName": "Acme Foundation",
                "recipientName": "Jordan"
              }'
      responses:
        "200":
          description: Sent, scheduled, or share link generated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendEcardResult" }
              examples:
                email:
                  summary: Email send
                  value:
                    success: true
                    type: email
                    scheduled: false
                    sendAt: ""
                    ecardVanityId: e4xgrzltk
                    widgetVanityId: w6v62rn95yq
                    senderEmail: development@acme.org
                    senderName: Acme Foundation
                    recipientName: Jordan
                    recipientEmail: jordan@example.com
                    locale: en
                    emailsSent: 1
                shareLink:
                  summary: Share link (no email)
                  value:
                    success: true
                    type: ecard_copy_link
                    scheduled: false
                    sendAt: ""
                    ecardVanityId: e4xgrzltk
                    widgetVanityId: w6v62rn95yq
                    senderEmail: development@acme.org
                    senderName: Acme Foundation
                    recipientName: Jordan
                    recipientEmail: ""
                    locale: en
                    statVanityId: s2k7zqpvnll272r
                    shareUrl: https://app.ecardwidget.com/e/s/s2k7zqpvnll272r
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /ecard/search:
    get:
      tags: [eCard Search (Enterprise)]
      summary: Search eCard designs (Enterprise, beta)
      operationId: searchEcards
      description: |
        Search and page through the eCard designs in your account.

        > **Enterprise-only and in active development.** This is a **dashboard (session-authenticated)**
        > endpoint — it authenticates with your logged-in session (`cfid`/`cftoken`), **not** the Bearer
        > API key. For a pure server-to-server flow, use `GET /pub/ecard-actions/get-ecards` instead.
      security:
        - sessionAuth: []
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 }, description: Page number }
        - { name: perPage, in: query, schema: { type: integer, default: 6 }, description: Results per page }
        - { name: keyword, in: query, schema: { type: string }, description: Free-text search over eCard name/details }
        - { name: widgetID, in: query, schema: { type: integer, default: 0 }, description: "Limit to a widget (0 = all)" }
        - { name: ecardIDs, in: query, schema: { type: string }, description: Comma-separated eCard IDs (numeric or vanity) }
        - { name: filterByDate, in: query, schema: { type: string, example: any-time }, description: "Date-range filter (e.g. any-time, last-week)" }
        - { name: sortBy, in: query, schema: { type: string, example: custom-order }, description: Sort order }
        - { name: includeCampaign, in: query, schema: { type: boolean, default: false }, description: "Include eCards from campaign widgets (excluded by default)" }
      x-codeSamples:
        - lang: cURL
          source: |
            curl 'https://app.ecardwidget.com/v2/api/ecard/search?page=1&perPage=6&filterByDate=any-time&sortBy=custom-order&widgetID=0' \
              -H 'accept: application/json'
      responses:
        "200":
          description: A page of eCard designs
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { type: array, items: { type: object } }
                      meta:
                        type: object
                        description: "Pagination plus account alerts/limits."
                        properties:
                          page: { type: integer }
                          per_page: { type: integer }
                          page_count: { type: integer }
                          total_count: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ───────────────────────── Directory Sync ─────────────────────────
  /pub/team-member-actions/upsert:
    post:
      tags: [Directory Sync]
      summary: Create or update a team member
      operationId: upsertTeamMember
      description: |
        Adds a member, or updates them if they already exist.

        - **PATCH semantics** — only the fields you send change; omitted fields are left untouched,
          so a partial sync never blanks existing data. To clear a field, send it as an empty string.
        - **Matching** — by default members match by `email`. To survive email changes, send a stable
          `employee_id` and set `match_field: employee_id`; then an email change updates the same
          person instead of creating a duplicate.
        - **Departments** auto-create by name; **manager** is linked by email (resolves on a later
          sync if the manager isn't in the directory yet).
        - Use **`dry_run: true`** to validate field mappings and see the would-be action without saving.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpsertRequest" }
            examples:
              basic:
                summary: Create/update by email
                value:
                  first_name: Grace
                  last_name: Hopper
                  email: grace@acme.com
                  job_position: Engineer
                  departments: ["Engineering"]
                  hire_date: "2021-03-01"
              stableId:
                summary: Match by employee ID (survives email changes)
                value:
                  first_name: Grace
                  last_name: Hopper
                  email: grace.h@acme.com
                  employee_id: EMP-100
                  match_field: employee_id
                  manager_email: boss@acme.com
              dryRun:
                summary: Validate without saving
                value:
                  first_name: Grace
                  email: grace@acme.com
                  dry_run: true
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://app.ecardwidget.com/v2/api/pub/team-member-actions/upsert \
              -H "Authorization: Bearer $ECW_API_KEY" -H "Content-Type: application/json" \
              -d '{
                "first_name": "Grace", "last_name": "Hopper", "email": "grace@acme.com",
                "employee_id": "EMP-100", "match_field": "employee_id",
                "job_position": "Engineer", "manager_email": "boss@acme.com",
                "departments": ["Engineering", "Leadership"], "hire_date": "2021-03-01"
              }'
      responses:
        "200":
          description: Member created/updated (or would-be action for a dry run)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/UpsertResult" }
              example:
                success: true
                data: { action: created, id: 12345, changed_fields: ["job_position"], warnings: [] }
                messages: []
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /pub/team-member-actions/find:
    get:
      tags: [Directory Sync]
      summary: Find a team member
      operationId: findTeamMember
      description: Looks up a single member by `email` or `employee_id`. `data` is empty when not found.
      parameters:
        - { name: email, in: query, schema: { type: string }, description: Match by email, example: grace@acme.com }
        - { name: employee_id, in: query, schema: { type: string }, description: Match by employee/external ID, example: EMP-100 }
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.ecardwidget.com/v2/api/pub/team-member-actions/find?email=grace@acme.com" \
              -H "Authorization: Bearer $ECW_API_KEY"
      responses:
        "200":
          description: The member (or empty when not found)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/MemberSummary" }
                      meta:
                        type: object
                        properties: { found: { type: boolean } }
              example:
                success: true
                meta: { found: true }
                data: { id: 12345, email: grace@acme.com, employee_id: EMP-100, first_name: Grace, last_name: Hopper, disabled: 0, updatedat: "2026-01-01T09:00:00Z" }
                messages: []
        "401": { $ref: "#/components/responses/Unauthorized" }

  /pub/team-member-actions/list:
    get:
      tags: [Directory Sync]
      summary: List team members (paginated)
      operationId: listTeamMembers
      description: |
        Pages through your directory — use it to **reconcile**: periodically list members, diff
        against your source system, and `deactivate` anyone no longer present (the streaming-safe
        replacement for a spreadsheet "Sync" that deletes everyone not in the file).

        Paginate with the **cursor**: pass the previous page's `meta.next_after_id` as `after_id`
        until `meta.has_more` is `false`. Use `updated_since` for delta pulls.
      parameters:
        - { name: status, in: query, description: "Which members to include.", schema: { type: string, enum: [active, deactivated, all], default: active } }
        - { name: perPage, in: query, description: "Page size (max 500).", schema: { type: integer, maximum: 500, example: 200 } }
        - { name: after_id, in: query, description: "Cursor — the previous page's meta.next_after_id.", schema: { type: string } }
        - { name: updated_since, in: query, description: "ISO datetime — only members changed since then.", schema: { type: string, format: date-time } }
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.ecardwidget.com/v2/api/pub/team-member-actions/list?status=active&perPage=200" \
              -H "Authorization: Bearer $ECW_API_KEY"
      responses:
        "200":
          description: A page of members
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/MemberSummary" } }
                      meta:
                        type: object
                        properties:
                          next_after_id: { type: string }
                          has_more: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /pub/team-member-actions/deactivate:
    post:
      tags: [Directory Sync]
      summary: Deactivate a team member (reversible)
      operationId: deactivateTeamMember
      description: |
        Reversibly deactivates a member by `email` or `employee_id`. They stop receiving automated
        cards and **no longer count toward your seat limit**, but their record and history are kept.
        Use this for normal offboarding; reactivate to restore (subject to seat availability).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberSelector" }
            example: { employee_id: EMP-100 }
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://app.ecardwidget.com/v2/api/pub/team-member-actions/deactivate \
              -H "Authorization: Bearer $ECW_API_KEY" -H "Content-Type: application/json" \
              -d '{"employee_id": "EMP-100"}'
      responses:
        "200": { $ref: "#/components/responses/MemberActionResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /pub/team-member-actions/reactivate:
    post:
      tags: [Directory Sync]
      summary: Reactivate a deactivated team member
      operationId: reactivateTeamMember
      description: Reactivates a previously deactivated member by `email` or `employee_id` (needs an available seat).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberSelector" }
            example: { employee_id: EMP-100 }
      responses:
        "200": { $ref: "#/components/responses/MemberActionResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /pub/team-member-actions/delete:
    post:
      tags: [Directory Sync]
      summary: Permanently delete a team member (GDPR erasure)
      operationId: deleteTeamMember
      description: |
        **Irreversible.** Permanently removes a member and their data — for GDPR erasure only.
        For normal offboarding use **Deactivate** instead.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberSelector" }
            example: { email: former.employee@acme.com }
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://app.ecardwidget.com/v2/api/pub/team-member-actions/delete \
              -H "Authorization: Bearer $ECW_API_KEY" -H "Content-Type: application/json" \
              -d '{"email": "former.employee@acme.com"}'
      responses:
        "200": { $ref: "#/components/responses/MemberActionResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ──────────────────────── Field discovery ────────────────────────
  /pub/team-member-actions/fields:
    get:
      tags: [Field discovery]
      summary: List mappable fields
      operationId: listFields
      description: |
        Returns the fields you can set on a member — standard fields plus your account's active
        **custom date fields** — each with a `label`, `type`, whether it's `required`, and the
        accepted `format`. Use this to drive field mapping in your integration.
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.ecardwidget.com/v2/api/pub/team-member-actions/fields" \
              -H "Authorization: Bearer $ECW_API_KEY"
      responses:
        "200":
          description: Mappable fields
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/FieldDef" } }
              example:
                success: true
                data:
                  - { field_name: email, label: Email, type: email, required: true, format: "" }
                  - { field_name: date_of_birth, label: Birthday, type: date, required: false, format: "YYYY-MM-DD" }
                  - { field_name: custom_date_1, label: Work Anniversary, type: date, required: false, format: "YYYY-MM-DD" }
                messages: []
        "401": { $ref: "#/components/responses/Unauthorized" }

  /pub/team-member-actions/departments:
    get:
      tags: [Field discovery]
      summary: List departments
      operationId: listDepartments
      description: Returns your account's departments. (Departments are also auto-created by name on upsert.)
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://app.ecardwidget.com/v2/api/pub/team-member-actions/departments" \
              -H "Authorization: Bearer $ECW_API_KEY"
      responses:
        "200":
          description: Departments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties: { id: { type: integer }, name: { type: string } }
              example:
                success: true
                data: [{ id: 12, name: HR }, { id: 58, name: Engineering }, { id: 66, name: Sales }]
                messages: []
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ───────────────────────────── Webhooks ─────────────────────────────
  /pub/team-member-actions/subscribe:
    post:
      tags: [Webhooks]
      summary: Subscribe to a member-change event
      operationId: subscribeWebhook
      description: |
        Registers a `hookUrl` to receive a signed JSON `POST` whenever the chosen `event_type`
        happens. Returns a subscription `id` (use it to unsubscribe).

        **Event types:** `team_member_created`, `team_member_updated`, `team_member_deactivated`,
        `team_member_deleted`. `team_member_updated` fires **only on a real change** (idempotent
        re-syncs don't notify). The initial spreadsheet import does **not** emit events.

        **Delivery & signing:** each delivery is signed with your account's webhook secret
        (`X-ECW-Signature: sha256=<hmac>` over the raw body) and retried on failure. De-duplicate
        with the payload's `event_id`. See the `WebhookDelivery` schema for the payload shape.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_type, hookUrl]
              properties:
                event_type:
                  type: string
                  enum: [team_member_created, team_member_updated, team_member_deactivated, team_member_deleted]
                hookUrl: { type: string, format: uri, example: "https://your-app.example.com/hooks/ecw" }
            example: { event_type: team_member_updated, hookUrl: "https://your-app.example.com/hooks/ecw" }
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://app.ecardwidget.com/v2/api/pub/team-member-actions/subscribe \
              -H "Authorization: Bearer $ECW_API_KEY" -H "Content-Type: application/json" \
              -d '{"event_type": "team_member_updated", "hookUrl": "https://your-app.example.com/hooks/ecw"}'
      responses:
        "200":
          description: Subscription created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      data: { type: object, properties: { id: { type: string } } }
              example: { success: true, data: { id: "sub_a1b2c3" }, messages: [] }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /pub/team-member-actions/unsubscribe:
    post:
      tags: [Webhooks]
      summary: Remove a webhook subscription
      operationId: unsubscribeWebhook
      description: Removes a subscription by the `id` returned from subscribe.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: string } } }
            example: { id: "sub_a1b2c3" }
      responses:
        "200": { $ref: "#/components/responses/MemberActionResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API_KEY
      description: Your account API key, from **Settings → Developers → API Keys**.
    sessionAuth:
      type: apiKey
      in: cookie
      name: cfid
      description: |
        Dashboard session credentials (`cfid` + `cftoken`). Used only by the Enterprise/beta
        `GET /ecard/search` endpoint — all other endpoints use the Bearer API key.

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Envelope" }
          example: { success: false, data: null, messages: ["Invalid or expired API key."] }
    BadRequest:
      description: Missing or invalid parameter
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Envelope" }
          example: { success: false, data: null, messages: ["Missing or invalid recipientEmail parameter"] }
    NotFound:
      description: Not found, or not owned by your account
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Envelope" }
          example: { success: false, data: null, messages: ["eCard not found or you don't have permission to access it"] }
    Unprocessable:
      description: Validation failed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Envelope" }
          example: { success: false, data: null, messages: ["first_name is required"] }
    MemberActionResult:
      description: Action result
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Envelope"
              - type: object
                properties:
                  data:
                    type: object
                    properties:
                      action: { type: string, example: deactivated }
                      id: { type: integer, example: 12345 }
          example: { success: true, data: { action: deactivated, id: 12345 }, messages: [] }

  schemas:
    Envelope:
      type: object
      description: Standard response envelope used by most endpoints.
      properties:
        success: { type: boolean }
        data: { nullable: true, description: Endpoint-specific payload (object or array). }
        messages: { type: array, items: { type: string } }

    EcardListItem:
      type: object
      properties:
        widgetid: { type: string, description: Widget vanity ID }
        ecardid: { type: string, description: eCard vanity ID — pass this to Send eCard }
        ecard_name: { type: string }
        ecard_details: { type: string, description: The eCard's HTML body }
        cover_image: { type: string, format: uri }
        cover_thumb_image: { type: string, format: uri }
        envelope_bg_image_url: { type: string, format: uri }

    SendEcardRequest:
      type: object
      required: [ecardId, senderEmail, senderName, recipientName]
      properties:
        ecardId: { type: string, description: eCard vanity ID (from get-ecards), example: e4xgrzltk }
        senderEmail: { type: string, format: email, description: Who the eCard is from }
        senderName: { type: string, description: Sender display name }
        recipientName: { type: string, description: Recipient display name }
        recipientEmail:
          type: string
          format: email
          description: "Required for email sends. Optional/ignored for share links (type=ecard_copy_link)."
        personalMessage: { type: string, description: "Optional personal note; basic HTML allowed." }
        type:
          type: string
          enum: [email, ecard_copy_link]
          default: email
          description: "email (default) emails it; ecard_copy_link returns a shareUrl instead of emailing."
        is_sharing: { type: boolean, default: false, description: "Equivalent to type=ecard_copy_link." }
        sendAt:
          type: string
          format: date-time
          description: "Leave blank to send now; a FUTURE value schedules the send."
        locale:
          type: string
          default: en
          description: "Language the eCard renders in. These are the supported render locales; an unlisted value isn't rejected — it falls back to the default (en)."
          enum: [en, spanish, dutch, hebrew, korean, de, french, arabic, polish, brazilian-portuguese, nam]
        optin: { type: boolean, description: Sender opt-in flag }
        opennotification: { type: boolean, description: Notify the sender when the eCard is opened }
        mergeTags:
          type: object
          additionalProperties: { type: string }
          description: "Key/value pairs that fill custom fields / merge tags in the eCard."
          example: { org_name: "Acme Corp", match_amount: "$500" }
        overrideProps:
          type: object
          description: "Optional per-send branding/eCard overrides."
          properties:
            logo_url: { type: string, format: uri, description: Organization logo }
            website: { type: string, format: uri, description: Logo link target }
            image_url: { type: string, format: uri, description: eCard image }
            details: { type: string, description: HTML body override }
            stylebghead: { type: string, description: Header background color }
            styleheadtext: { type: string, description: Header text color }
            stylebgbody: { type: string, description: Body background color }
            styletext: { type: string, description: Text color }
            stylelink: { type: string, description: Link color }
            ecard_mode: { type: string, enum: [popup_card, classic] }

    SendEcardResult:
      type: object
      description: "Flat result (no envelope). Email sends include emailsSent; share sends include statVanityId + shareUrl."
      properties:
        success: { type: boolean }
        type: { type: string, enum: [email, ecard_copy_link] }
        scheduled: { type: boolean, description: True when a future sendAt scheduled it }
        sendAt: { type: string, description: The scheduled time (blank when sent immediately) }
        ecardVanityId: { type: string }
        widgetVanityId: { type: string }
        senderEmail: { type: string }
        senderName: { type: string }
        recipientName: { type: string }
        recipientEmail: { type: string, description: Blank for share-link sends }
        locale: { type: string }
        emailsSent: { type: integer, description: Email mode only }
        statVanityId: { type: string, description: Share mode only — the shared card's vanity ID }
        shareUrl: { type: string, description: "Share mode only — https://app.ecardwidget.com/e/s/{statVanityId}" }

    UpsertRequest:
      type: object
      required: [first_name, email]
      properties:
        first_name: { type: string }
        email: { type: string, format: email }
        last_name: { type: string }
        job_position: { type: string }
        profile_image_url: { type: string, format: uri, description: Must be publicly fetchable }
        employee_id: { type: string, description: Stable external ID (recommended for HR syncs) }
        manager_email: { type: string, format: email, description: Linked to an existing member by email }
        department: { type: string, description: Single department name (auto-created if missing) }
        departments:
          type: array
          items: { type: string }
          description: Multiple department names (auto-created if missing)
        date_of_birth: { type: string, description: "Birthday — year is privacy-masked; accepts YYYY-MM-DD, M/D/YYYY, …" }
        hire_date: { type: string, description: Work anniversary / hire date }
        active:
          type: boolean
          description: "false deactivates, true reactivates; OMIT to leave unchanged (upsert never silently reactivates)."
        match_field: { type: string, enum: [email, employee_id], default: email }
        mode: { type: string, enum: [add_update, add_new_only], default: add_update, description: "add_new_only skips members that already exist." }
        skip_if_missing_required: { type: boolean, description: "Skip (don't error) rows missing first_name/email. Invalid emails still error." }
        dry_run: { type: boolean, description: "Validate and report the would-be action without saving." }
      additionalProperties:
        description: "custom_date_1..N are also accepted — discover them via GET /fields."

    UpsertResult:
      type: object
      properties:
        action: { type: string, enum: [created, updated, unchanged, skipped], description: "Sending active:false sets the member inactive but the action is still created/updated; deactivated/reactivated/deleted come from those dedicated endpoints." }
        id: { type: integer }
        changed_fields: { type: array, items: { type: string }, description: Fields that actually changed (drives the updated webhook). }
        warnings: { type: array, items: { type: string }, description: Non-fatal notes (e.g. manager not found yet). }

    MemberSummary:
      type: object
      description: "The member shape returned by find/list (a summary). The fuller object — with job title, departments, dates, manager — is delivered in member-change webhooks (see Member)."
      properties:
        id: { type: integer }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string }
        employee_id: { type: string }
        disabled: { type: integer, description: "1 = deactivated, 0 = active" }
        updatedat: { type: string, format: date-time }

    Member:
      type: object
      description: "Full member object as delivered in member-change webhook payloads."
      properties:
        id: { type: integer }
        email: { type: string }
        employee_id: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        job_position: { type: string }
        manager_id: { type: integer, description: "Numeric id of the member's manager, if linked." }
        departments: { type: array, items: { type: string } }
        date_of_birth: { type: string, description: "Year is privacy-masked (stored as 1800)." }
        hire_date: { type: string }
        profile_image_url: { type: string }
        disabled: { type: integer, description: "1 = deactivated, 0 = active" }
        updatedat: { type: string, format: date-time }

    MemberSelector:
      type: object
      description: Provide either email or employee_id.
      properties:
        email: { type: string }
        employee_id: { type: string }

    FieldDef:
      type: object
      properties:
        field_name: { type: string }
        label: { type: string }
        type: { type: string }
        required: { type: boolean }
        format: { type: string }

    WebhookDelivery:
      type: object
      description: |
        The signed JSON body POSTed to your `hookUrl`. Header `X-ECW-Signature: sha256=<hmac>` is the
        HMAC-SHA256 of the raw body using your account's webhook secret — verify it before trusting.
      properties:
        event_id: { type: string, description: Stable id — use for de-duplication across retries }
        event_type: { type: string, enum: [team_member_created, team_member_updated, team_member_deactivated, team_member_deleted] }
        timestamp: { type: string, format: date-time }
        source: { type: string, enum: [api, zapier, ui, import], description: Where the change originated (use to avoid sync loops) }
        changed_fields: { type: array, items: { type: string }, description: Present on team_member_updated }
        member: { $ref: "#/components/schemas/Member" }

    EcardSharedWebhook:
      type: object
      description: |
        The `ecard_shared` webhook body (a JSON **array** of these is POSTed to your dashboard Webhook
        URL with header `User-Agent: eCardWidget Hook`) whenever an eCard is sent/shared. For pending
        email sends the hook fires before dispatch (`sent: false`); immediate share/social sends arrive
        with `sent: true`. Use `scheduled_to_be_sent_at` for the expected send time.
      properties:
        event_type: { type: string, example: ecard_shared }
        type: { type: string, description: "Share method: email, facebook, twitter, linkedin, sms, whatsapp, …" }
        sender_request_id: { type: string }
        ecardid: { type: integer }
        widgetid: { type: integer }
        vanity_id: { type: string }
        sender_name: { type: string }
        sender_email: { type: string }
        recipient_name: { type: string }
        recipient_email: { type: string }
        personal_message: { type: string }
        locale: { type: string }
        scheduled_to_be_sent_at: { type: string, format: date-time }
        sent: { type: boolean, description: "false for pending email sends (hook fires pre-dispatch); true for immediate share/social sends" }
        opened: { type: boolean }
        merge_tags: { type: string }
        override_props: { type: string }
