openapi: 3.1.0
info:
  title: Rescova Integration API
  version: 1.2.0
  description: |
    Customer integration for debtor/claim batch ingestion, versioned record corrections,
    idempotent payment receipts and explicitly authorized campaign starts.
    Imports never activate outbound calling. A customer campaign is a named import batch,
    not a legacy campaign or a second execution engine. Start is a separate scoped action.
    Initial organization rollout, reconciliation, outbound permission and review remain authoritative.
    Promises and booked payments are separate. Later payment/agreement changes are read via
    current case snapshots; the outgoing event feed remains call.outcome.finalized only.
    Confirmed incoming payments are reported by the customer system. Automatic bank-account
    synchronization and payment initiation are outside this integration contract.
    Organization entitlement and API permissions are configured at onboarding.
    Import receipts, records, events and webhook endpoints retain original API-key ownership;
    a replacement API key does not automatically inherit access to existing data.
    Money is integer cents; payment/import currency is EUR. Dates are YYYY-MM-DD.
    Unknown fields in documented mutation payloads are rejected. Domain errors use {error:string}; malformed JSON,
    oversized bodies and proxy errors may have non-JSON bodies. Check Content-Type and status.
servers:
  - url: https://api.rescova.de
security:
  - bearerAuth: []
  - apiKeyAuth: []
paths:
  /v1/contact-imports:
    post:
      summary: Import debtors and claims without starting calls
      description: |
        Requires contacts:import and organization API ingestion entitlement. Partial import of 1-10000 records, maximum JSON body 16 MiB and nesting depth 64 (deeper payloads return 400 payload_too_deep). Valid contact groups are imported; invalid rows return errors with a 1-based row position, field, code and readable message. Rows linked by contact_external_ref or normalized phone are one group: any bad row rejects that group. Duplicate case references reject all affected rows. All-invalid input returns 400 no_valid_records without creating an import; all-domain-conflict rejection can return 409. Envelope, authorization, entitlement, policy/profile and technical errors remain request-level failures. Creates only inactive/review-held enrollments and shadow jobs. Existing identical records preserve their original batch, policy and progress. This is not a general upsert: identity/case changes and new claims on an already enrolled phone can return 409. Manual-review holds cannot be bypassed.
        Idempotency-Key is scoped to this API key. New commands hash the entire ordered input, including rejected rows, plus name, profile selection/version, policies and a validation version. Retry the request unchanged; reordering or changing a rejected row returns 409. Original normalized and legacy hashes remain supported only for replaying stored old receipts. Identical completed requests return the same counts and errors with replayed=true; changed payload returns 409 idempotency_key_payload_mismatch. Persisted failed attempts consume the key (409 idempotency_key_previously_failed); correct the data and use a new key. Network-uncertain successful requests should be retried unchanged with the same key.
        At most 20 persisted imports in a rolling minute per API key; failed batches may count. Completed replays are checked before the rate limit. No Retry-After is guaranteed.
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          schema: {type: string, minLength: 16, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ContactImportRequest'}
      responses:
        '201':
          description: Imported
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactImportReceipt'}
        '200':
          description: Idempotent replay of a completed import
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactImportReceipt'}
        '400': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '429': {$ref: '#/components/responses/Error'}
        '413':
          description: JSON body exceeds 16 MiB or proxy limit; body may be non-JSON.
        '401': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: importContacts
      tags:
        - Imports
      x-required-permission: contacts:import
  /v1/contact-imports/{import_id}:
    get:
      summary: Read import status and current processing counters
      description: Requires `imports:read`. Only imports created with the same API key are visible.
      parameters:
        - {$ref: '#/components/parameters/ImportId'}
      responses:
        '200':
          description: Current import and enrollment state
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactImportStatus'}
        '404': {$ref: '#/components/responses/Error'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: getContactImport
      tags:
        - Imports
      x-required-permission: imports:read
  /v1/integration-events:
    get:
      summary: Read finalized call outcomes and summaries
      description: |
        Requires events:read. Immutable call.outcome.finalized events belonging to this API key. Stable insertion order, not necessarily occurred_at order. No payment receipt or subsequent agreement-change events.
        Follow next_cursor unchanged while has_more=true. Terminal pages return next_cursor=null even if nonempty: retain the cursor used to request that page, poll again from it (or from the beginning if no cursor yet), and deduplicate by event.id. Do not replace a saved checkpoint with null or derive a cursor from the public event ID.
      parameters:
        - in: query
          name: cursor
          schema: {type: string}
        - in: query
          name: limit
          schema: {type: integer, minimum: 1, maximum: 100, default: 50}
        - in: query
          name: type
          schema:
            type: string
            enum:
              - call.outcome.finalized
      responses:
        '200':
          description: Ordered event page
          content:
            application/json:
              schema: {$ref: '#/components/schemas/IntegrationEventPage'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: listIntegrationEvents
      tags:
        - Events
      x-required-permission: events:read
  /v1/webhook-endpoints:
    get:
      summary: List webhook endpoints
      description: Requires `webhooks:manage`.
      responses:
        '200':
          description: Key-scoped endpoints, including disabled entries; no secrets
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookEndpointList'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: listWebhookEndpoints
      tags:
        - Webhooks
      x-required-permission: webhooks:manage
    post:
      summary: Register a webhook endpoint
      description: |
        Requires webhooks:manage. Up to ten active public HTTPS/443 targets per API key. Registration subscribes future event creation; historical events are not automatically backfilled. Registering an enabled URL returns 409 webhook_endpoint_exists. Registering a disabled URL reactivates it with a new secret. No Idempotency-Key support: after a lost response, inspect the list and rotate the secret if necessary.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookEndpointInput'}
      responses:
        '200':
          description: Disabled endpoint reactivated with a new one-time secret; old pending deliveries are not
            replayed
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookEndpointCreated'}
        '201':
          description: Endpoint created with one-time signing_secret
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookEndpointCreated'}
        '400': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '413':
          description: Body exceeds 16 KiB; response may be non-JSON.
        '503': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: createWebhookEndpoint
      tags:
        - Webhooks
      x-required-permission: webhooks:manage
  /v1/webhook-endpoints/{endpoint_id}/rotate-secret:
    post:
      summary: Rotate a webhook signing secret
      description: Requires webhooks:manage. Replacement secret shown once; effective immediately for subsequently
        claimed deliveries, no dual-secret grace period. In-flight deliveries may still use the previous secret.
        Rotation does not enable a disabled endpoint.
      parameters:
        - {$ref: '#/components/parameters/EndpointId'}
      responses:
        '200':
          description: Secret replaced
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookEndpointRotated'}
        '404': {$ref: '#/components/responses/Error'}
        '400': {$ref: '#/components/responses/Error'}
        '413':
          description: Body exceeds 16 KiB; response may be non-JSON.
        '503': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: rotateWebhookSecret
      tags:
        - Webhooks
      x-required-permission: webhooks:manage
  /v1/webhook-endpoints/{endpoint_id}:
    delete:
      summary: Disable a webhook endpoint
      description: Requires webhooks:manage. Disables the endpoint and cancels pending deliveries. Already disabled/missing/other-key
        endpoints return 404. In-flight requests may still arrive.
      parameters:
        - {$ref: '#/components/parameters/EndpointId'}
      responses:
        '204':
          description: Endpoint disabled
        '404': {$ref: '#/components/responses/Error'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: disableWebhookEndpoint
      tags:
        - Webhooks
      x-required-permission: webhooks:manage
  /v1/contact-imports/profiles:
    get:
      operationId: listCommunicationProfiles
      tags:
        - Imports
      summary: List active sender/payment profiles
      description: Requires contacts:import. Returns profiles of the authenticated organization; no bank details
        are returned. Exactly one active profile can be selected implicitly; multiple profiles require an explicit
        ID on import. A profile combines the sender name used in conversations with payment recipient details,
        such as payee name and IBAN. Profiles are managed in the platform knowledge base; this API lists their
        metadata for campaign selection. Each campaign pins a copy at creation, so later profile edits do not
        change existing campaigns.
      responses:
        '200':
          description: Active profiles
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CommunicationProfileList'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      x-required-permission: contacts:import
  /v1/campaigns:
    post:
      summary: Create a named campaign batch without starting calls
      description: |-
        Requires contacts:import and organization API ingestion entitlement. Partial import of 1-10000 records, maximum JSON body 16 MiB and nesting depth 64 (deeper payloads return 400 payload_too_deep). Valid contact groups are imported; invalid rows return errors with a 1-based row position, field, code and readable message. Rows linked by contact_external_ref or normalized phone are one group: any bad row rejects that group. Duplicate case references reject all affected rows. All-invalid input returns 400 no_valid_records without creating an import; all-domain-conflict rejection can return 409. Envelope, authorization, entitlement, policy/profile and technical errors remain request-level failures. Creates only inactive/review-held enrollments and shadow jobs. Existing identical records preserve their original batch, policy and progress. This is not a general upsert: identity/case changes and new claims on an already enrolled phone can return 409. Manual-review holds cannot be bypassed.
        Idempotency-Key is scoped to this API key. New commands hash the entire ordered input, including rejected rows, plus name, profile selection/version, policies and a validation version. Retry the request unchanged; reordering or changing a rejected row returns 409. Original normalized and legacy hashes remain supported only for replaying stored old receipts. Identical completed requests return the same counts and errors with replayed=true; changed payload returns 409 idempotency_key_payload_mismatch. Persisted failed attempts consume the key (409 idempotency_key_previously_failed); correct the data and use a new key. Network-uncertain successful requests should be retried unchanged with the same key.
        At most 20 persisted imports in a rolling minute per API key; failed batches may count. Completed replays are checked before the rate limit. No Retry-After is guaranteed.

        Alias of POST /v1/contact-imports. The receipt import_id is the campaign identifier; both names address the same resource and idempotency namespace.
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          schema: {type: string, minLength: 16, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ContactImportRequest'}
      responses:
        '201':
          description: Imported
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactImportReceipt'}
        '200':
          description: Idempotent replay of a completed import
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactImportReceipt'}
        '400': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '429': {$ref: '#/components/responses/Error'}
        '413':
          description: JSON body exceeds 16 MiB or proxy limit; body may be non-JSON.
        '401': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: createCampaign
      tags:
        - Campaigns
      x-required-permission: contacts:import
  /v1/campaigns/{import_id}:
    get:
      summary: Read campaign import status and current processing counters
      description: Requires `imports:read`. Only imports created with the same API key are visible.
      parameters:
        - {$ref: '#/components/parameters/ImportId'}
      responses:
        '200':
          description: Current import and enrollment state
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactImportStatus'}
        '404': {$ref: '#/components/responses/Error'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      operationId: getCampaign
      tags:
        - Campaigns
      x-required-permission: imports:read
  /v1/campaigns/{import_id}/start:
    post:
      operationId: startCampaign
      tags:
        - Campaigns
      summary: Explicitly release eligible contacts of your own campaign
      description: |
        Requires campaigns:start and {confirm:true}. Idempotency-Key required. Only a completed API import belonging to the same key can be started. Existing organization API entitlement, rollout, outbound permission, hybrid/case_queue mode and reconciled cutover must already exist. This endpoint never migrates or grants an organization.
        Uses the existing deterministic eligibility gate. Reviews, disputes, suppressions, active agreements, terminal states, contact limits and running calls are not bypassed. Jobs preserve scheduling/callback times; actual dialing occurs within the scheduler's windows and capacity limits, not during the HTTP request.
        A paused organization is resumed only if no unrelated callable enrollment, unreleased non-API phone-check workflow or active payment agreement/pending message would also resume. Otherwise 409 organization_resume_requires_review: review the organization in Rescova. A successful request may release zero contacts and report blocked_reasons. phone_check_pending means import phone checks must finish first. Completion never automatically releases API batches; a new explicit start with a new idempotency key is then required.
        Replay returns the original immutable result and never re-enables an organization paused since that result. A consciously new release needs a new idempotency key. Pausing remains available in Rescova.
      parameters:
        - {$ref: '#/components/parameters/ImportId'}
        - in: header
          name: Idempotency-Key
          required: true
          schema: {type: string, minLength: 16, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/CampaignStartRequest'}
      responses:
        '200':
          description: Explicit start result, or immutable replay
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CampaignStartReceipt'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      x-required-permission: campaigns:start
  /v1/payments:
    post:
      operationId: recordPayment
      tags:
        - Payments
      summary: Book a confirmed partial/full payment or explicit plan installment
      description: |
        Requires payments:write. Maximum JSON body 16 KiB. An integer EUR receipt updates the outstanding balance atomically; full settlement marks the case PAID and cancels appropriate not-yet-started work. Existing holds/consent and already submitted calls/messages are not overwritten.
        Idempotency-Key and stable external_payment_ref required. The external reference is unique across the organization including other API keys. Identical retries return the stored receipt (200); conflicting key/reference content returns 409. Authorization and originating import ownership are checked before booking.
        If a payable tracked plan exists, payment_plan_id and installment_number must identify one unpaid installment and amount_cents must equal that actual installment amount. One POST books one whole installment; multiple installments require separate allocations with distinct stable external references or manual reconciliation. Read the current case payment_plan.installment_schedule first. A pending_approval plan is not payable. Existing authoritative fee/discount/last-rate allocation is reused. Ambiguous/unassigned money returns 409 payment_reconciliation_required or installment_payment_required.
        Overpayment, arbitrary partial allocation within a tracked installment and API reversals are unsupported. No payment.received webhook is emitted in this version; read the case snapshot to reconcile.
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          schema: {type: string, minLength: 16, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/PaymentReceiptRequest'}
      responses:
        '201':
          description: Receipt committed with replayed=false
          content:
            application/json:
              schema: {$ref: '#/components/schemas/PaymentReceiptResult'}
        '200':
          description: Original committed receipt with replayed=true
          content:
            application/json:
              schema: {$ref: '#/components/schemas/PaymentReceiptResult'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
        '503': {$ref: '#/components/responses/Error'}
        '413':
          description: Body exceeds 16 KiB; response may be non-JSON.
      x-required-permission: payments:write
  /v1/payments/{payment_id}:
    get:
      operationId: getPaymentReceipt
      tags:
        - Payments
      summary: Read your immutable API payment receipt
      description: Requires payments:read and originating import/key ownership. This is the original booking snapshot,
        including its original outstanding amount; later payments or reversals do not rewrite it.
      parameters:
        - in: path
          name: payment_id
          required: true
          description: Use id from the POST /v1/payments response (pay_ followed by 32 hexadecimal characters), not the numeric ledger payment_id in an installment schedule.
          schema: {type: string, pattern: '^pay_[a-f0-9]{32}$'}
      responses:
        '200':
          description: Original booking receipt; no replayed field
          content:
            application/json:
              schema: {$ref: '#/components/schemas/PaymentReceipt'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      x-required-permission: payments:read
  /v1/contacts/{contact_external_ref}:
    get:
      operationId: getContact
      tags:
        - Records
      summary: Read a debtor contact and its correction version
      description: Requires contacts:read. Only contacts associated with the original importing API key are visible.
        Returns name, phone, email and a string correction version. The strong ETag matches that version;
        it is not a claim balance or payment agreement snapshot.
      parameters:
        - in: path
          name: contact_external_ref
          required: true
          schema: {type: string, minLength: 1, maxLength: 160}
          description: URL-encoded contact reference; case-sensitive. Use the exact imported reference and original API key.
      responses:
        '200':
          description: Current record
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactSnapshot'}
          headers:
            ETag:
              description: Strong quoted version token, e.g. "3".
              schema: {type: string}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      x-required-permission: contacts:read
    patch:
      operationId: patchContact
      tags:
        - Records
      summary: Correct selected contact fields using a version precondition
      description: |
        Requires contacts:write and organization API-ingestion entitlement. Maximum JSON body 16 KiB. Supply either expected_version in the body or a strong numeric If-Match value such as "3". If both are provided they must agree. No wildcard or weak ETag. Missing precondition returns 428; stale version returns 412. A lost successful response can be recovered by rereading and comparing; PATCH has no Idempotency-Key support.
        No arbitrary balance/state/original-amount/reference writes. Actual phone changes require manual review. Existing legacy memberships, live calls, processing jobs and pending post-call finalization fail closed with 409. Shared contact ownership also blocks contact PATCH; own-case metadata can still be corrected. Busy authorization/reservation locks may also return 409; reread/retry with backoff.
        Name or case metadata changes cancel not-yet-started jobs, preserve safety holds and require a separate explicit release. Email-only corrections preserve scheduling. Identical data returns changed=false. No new call is authorized by PATCH. There are no record-change webhooks in this version.
      parameters:
        - in: path
          name: contact_external_ref
          required: true
          schema: {type: string, minLength: 1, maxLength: 160}
          description: URL-encoded contact reference; case-sensitive. Use the exact imported reference and original API key.
        - in: header
          name: If-Match
          required: false
          schema: {type: string, pattern: '^"[1-9][0-9]{0,18}"$'}
          description: Strong quoted current version; alternative to body expected_version.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ContactPatch'}
      responses:
        '200':
          description: Updated or unchanged record
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ContactPatchResult'}
          headers:
            ETag:
              description: Strong quoted version token, e.g. "3".
              schema: {type: string}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '412': {$ref: '#/components/responses/Error'}
        '428': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
        '413':
          description: Body exceeds 16 KiB; response may be non-JSON.
      x-required-permission: contacts:write
  /v1/cases/{case_external_ref}:
    get:
      operationId: getCase
      tags:
        - Records
      summary: Read a claim with its current balance and agreements
      description: Requires cases:read. Only records associated with the original importing API key are visible.
        Snapshot versions are strings; case snapshots include current balance and current/latest agreements. Case
        GET always returns the current full snapshot without an ETag or conditional304; its body version is the
        metadata correction token.
      parameters:
        - in: path
          name: case_external_ref
          required: true
          schema: {type: string, minLength: 1, maxLength: 160}
          description: URL-encoded stable external reference; use original API key. Case references match case-insensitively.
      responses:
        '200':
          description: Current record
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CaseSnapshot'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
      x-required-permission: cases:read
    patch:
      operationId: patchCase
      tags:
        - Records
      summary: Correct claim metadata using a version precondition
      description: |
        Requires cases:write and organization API-ingestion entitlement. Maximum JSON body 16 KiB. Supply either expected_version in the body or a strong numeric If-Match value such as "3". If both are provided they must agree. No wildcard or weak ETag. Missing precondition returns 428; stale version returns 412. A lost successful response can be recovered by rereading and comparing; PATCH has no Idempotency-Key support.
        No arbitrary balance/state/original-amount/reference writes. Actual phone changes require manual review. Existing legacy memberships, live calls, processing jobs and pending post-call finalization fail closed with 409. Shared contact ownership also blocks contact PATCH; own-case metadata can still be corrected. Busy authorization/reservation locks may also return 409; reread/retry with backoff.
        Name or case metadata changes cancel not-yet-started jobs, preserve safety holds and require a separate explicit release. Email-only corrections preserve scheduling. Identical data returns changed=false. No new call is authorized by PATCH. There are no record-change webhooks in this version.
      parameters:
        - in: path
          name: case_external_ref
          required: true
          schema: {type: string, minLength: 1, maxLength: 160}
          description: URL-encoded stable external reference; use original API key. Case references match case-insensitively.
        - in: header
          name: If-Match
          required: false
          schema: {type: string, pattern: '^"[1-9][0-9]{0,18}"$'}
          description: Strong quoted current version; alternative to body expected_version.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/CasePatch'}
      responses:
        '200':
          description: Updated or unchanged record
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CasePatchResult'}
          headers:
            ETag:
              description: Strong quoted version token, e.g. "3".
              schema: {type: string}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
        '409': {$ref: '#/components/responses/Error'}
        '412': {$ref: '#/components/responses/Error'}
        '428': {$ref: '#/components/responses/Error'}
        '500': {$ref: '#/components/responses/Error'}
        '413':
          description: Body exceeds 16 KiB; response may be non-JSON.
      x-required-permission: cases:write
components:
  securitySchemes:
    bearerAuth: {type: http, scheme: bearer}
    apiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
  parameters:
    ImportId:
      in: path
      name: import_id
      required: true
      schema:
        type: string
        pattern: ^[1-9][0-9]{0,18}$
        description: Opaque numeric identifier serialized as a string; do not infer other resource IDs.
    EndpointId:
      in: path
      name: endpoint_id
      required: true
      schema:
        type: string
        pattern: ^[1-9][0-9]{0,18}$
        description: Opaque numeric identifier serialized as a string; do not infer other resource IDs.
  responses:
    Error:
      description: 'Domain/authentication error. 401: missing/invalid key; 403: disabled key/organization, missing
        permission or import entitlement; 400: invalid input; 409: identity/idempotency/profile/endpoint conflict;
        429: import rate limit; 500/503: service/configuration error. Parser/proxy errors may use non-JSON bodies.'
      content:
        application/json:
          schema:
            type: object
            required:
              - error
            properties:
              error: {type: string}
              total_records: {type: integer}
              imported_records: {type: integer, const: 0}
              rejected_records: {type: integer}
              errors:
                type: array
                items: {$ref: '#/components/schemas/ImportRowError'}
  schemas:
    CallWindow:
      type: object
      additionalProperties: false
      required:
        - day
        - start
        - end
      properties:
        day:
          type: integer
          minimum: 0
          maximum: 6
          description: 0 is Sunday
        start: {type: string, pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'}
        end: {type: string, pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'}
      description: Local time in the configured timezone. start must precede end; no overnight or overlapping windows
        for the same day.
    CallingOverrides:
      type: object
      additionalProperties: false
      properties:
        timezone: {type: string, example: Europe/Berlin}
        call_windows:
          type: array
          minItems: 1
          maxItems: 35
          items: {$ref: '#/components/schemas/CallWindow'}
        max_contact_attempts: {type: integer, minimum: 1, maximum: 20}
        max_attempts_per_24h: {type: integer, minimum: 1, maximum: 20}
        max_attempts_per_7d: {type: integer, minimum: 1, maximum: 100}
        retry_interval_minutes: {type: integer, minimum: 1, maximum: 10080}
      description: Sparse overrides of organization calling settings. Effective max_attempts_per_24h <= max_attempts_per_7d
        <= max_contact_attempts. All effective fields are validated together.
    PaymentOverrides:
      type: object
      description: 'Complete immutable payment-policy snapshot for this batch. Omitted fields are filled with disabled
        or zero defaults when the batch is created; later organization-policy changes are not inherited. Defaults:
        installments_enabled=false, max_installments=null, fees/minimums=0, discount_enabled=false, percentages=0.
        Enabled installments require max_installments. Enabled discounts must not exceed the organization maximum.'
      additionalProperties: false
      properties:
        installments_enabled: {type: boolean}
        max_installments:
          type:
            - integer
            - 'null'
          minimum: 2
          maximum: 24
        installment_fee_cents: {type: integer, minimum: 0, maximum: 2147483647}
        installment_min_cents: {type: integer, minimum: 0, maximum: 2147483647}
        discount_enabled: {type: boolean}
        discount_24h_pct: {type: number, minimum: 0, maximum: 100, multipleOf: 0.01}
        discount_7d_pct: {type: number, minimum: 0, maximum: 100, multipleOf: 0.01}
    ContactRecord:
      type: object
      additionalProperties: false
      required:
        - contact_external_ref
        - name
        - phone_e164
        - case_external_ref
        - invoice_number
        - amount_cents
        - due_date
        - creditor_name
      properties:
        contact_external_ref:
          type: string
          minLength: 1
          maxLength: 160
          description: Key-scoped stable contact reference; trimmed and whitespace-normalized.
        name: {type: string, minLength: 1, maxLength: 200}
        phone_e164:
          type: string
          pattern: ^\+[1-9][0-9]{6,14}$
          description: Send canonical E.164. Import also normalizes whitespace, parentheses, slash, hyphen and a
            leading 00.
        email:
          anyOf:
            - {type: string, format: email, maxLength: 254}
            - {const: ''}
            - {type: 'null'}
          description: Trimmed and lowercased; null/empty is treated as absent.
        case_external_ref:
          type: string
          minLength: 1
          maxLength: 160
          description: Organization-wide claim reference, matched case-insensitively; returned spelling may be normalized.
        invoice_number: {type: string, minLength: 1, maxLength: 160}
        amount_cents:
          type: integer
          minimum: 1
          maximum: 2147483647
          description: EUR cents; initial claim amount. Reimport is not a balance update.
        due_date: {type: string, format: date}
        file_number:
          type:
            - string
            - 'null'
          maxLength: 160
        sub_file_number:
          type:
            - string
            - 'null'
          maxLength: 160
        creditor_name:
          type: string
          minLength: 1
          x-normalized-maxLength: 200
          description: Required original creditor, independent of sender/payment profile. Unicode NFC and whitespace normalized; up to 200 Unicode codepoints. At least one letter or number required. Control characters and direction overrides are rejected; no legal-form or dictionary filter.
        note:
          type:
            - string
            - 'null'
          maxLength: 5000
        enforcement_title:
          anyOf:
            - {$ref: '#/components/schemas/EnforcementTitle'}
            - {type: 'null'}
    ContactImportRequest:
      type: object
      additionalProperties: false
      required:
        - records
      properties:
        name: {type: string, minLength: 1, maxLength: 200, default: API import}
        calling_overrides: {$ref: '#/components/schemas/CallingOverrides'}
        payment_overrides: {$ref: '#/components/schemas/PaymentOverrides'}
        records:
          type: array
          minItems: 1
          maxItems: 10000
          items: {$ref: '#/components/schemas/ContactRecord'}
        communication_profile_id:
          type: string
          pattern: ^[1-9][0-9]*$
          description: Active profile from GET /v1/contact-imports/profiles. Required when more than one is active.
            Send as a string; runtime also accepts a positive safe integer.
        communication_profile_version:
          type: integer
          minimum: 1
          description: Optional optimistic version check for the selected profile; mismatch returns 409.
      examples:
        - name: Offene Forderungen Oktober 2026
          communication_profile_id: '42'
          communication_profile_version: 3
          payment_overrides: {installments_enabled: true, max_installments: 6, installment_min_cents: 2500}
          records:
            - contact_external_ref: debtor-0042
              name: Erika Beispiel
              phone_e164: '+4917612345678'
              email: erika@example.org
              case_external_ref: claim-1042
              invoice_number: RE-1042
              amount_cents: 15000
              due_date: '2026-09-30'
              file_number: AZ-1042
              creditor_name: Beispielhandel GmbH
    ImportRowError:
      type: object
      required: [row, field, code, message]
      properties:
        row: {type: integer, minimum: 1, description: 1-based position in the submitted records array.}
        field: {type: string}
        code: {type: string}
        message: {type: string, description: Human-readable German explanation without raw debtor values.}
        contact_external_ref: {type: string}
        case_external_ref: {type: string}
    ContactImportReceipt:
      type: object
      required:
        - import_id
        - imported_records
        - total_records
        - rejected_records
        - errors
        - replayed
        - scheduled_shadow_jobs
        - unchanged_records
        - review_required
        - executable_call_jobs
      properties:
        import_id: {type: string}
        total_records: {type: integer, minimum: 1}
        rejected_records: {type: integer, minimum: 0}
        errors:
          type: array
          items: {$ref: '#/components/schemas/ImportRowError'}
        imported_records:
          type: integer
          description: Processed records, not necessarily newly created contacts or claims.
        replayed: {type: boolean}
        scheduled_shadow_jobs:
          type: integer
          description: Newly prepared non-executable jobs.
        unchanged_records: {type: integer}
        review_required:
          type: integer
          description: Imported processing entries/enrollments held for review; not a record count.
        executable_call_jobs: {type: integer, const: 0}
      examples:
        - import_id: '123'
          imported_records: 1
          total_records: 1
          rejected_records: 0
          errors: []
          replayed: false
          scheduled_shadow_jobs: 1
          unchanged_records: 0
          review_required: 0
          executable_call_jobs: 0
    ContactImportStatus:
      type: object
      required:
        - import_id
        - name
        - status
        - total_records
        - imported_records
        - rejected_records
        - scheduled_shadow_jobs
        - unchanged_records
        - review_required
        - executable_call_jobs
      properties:
        import_id: {type: string}
        name: {type: string}
        status:
          type: string
          enum:
            - processing
            - completed
            - failed
        total_records: {type: integer}
        imported_records: {type: integer}
        rejected_records: {type: integer}
        errors:
          type: array
          description: New row rejections include row, field and message; historical whole-import errors may contain only code.
          items:
            type: object
            required: [code]
            properties:
              code: {type: string}
              row: {type: integer, minimum: 1}
              field: {type: string}
              message: {type: string}
              contact_external_ref: {type: string}
              case_external_ref: {type: string}
        calling_overrides: {$ref: '#/components/schemas/CallingOverrides'}
        payment_overrides: {$ref: '#/components/schemas/PaymentOverrides'}
        scheduled_shadow_jobs: {type: integer}
        unchanged_records: {type: integer}
        review_required:
          type: integer
          description: Processing entries/enrollments held for review at import time; not a record count.
        current_enrollment_count: {type: integer}
        current_review_required: {type: integer}
        current_shadow_jobs: {type: integer}
        executable_call_jobs:
          type: integer
          description: Current pending/processing jobs; can exceed zero after separate operator activation.
        created_at: {type: string, format: date-time}
        completed_at:
          type:
            - string
            - 'null'
          format: date-time
      description: Import-time counters, row errors and current aggregate enrollment/job state; no debtor or claim records. A status
        lookup requires the original importing API key.
    IntegrationEvent:
      type: object
      required:
        - id
        - type
        - schema_version
        - occurred_at
        - data
      properties:
        id: {type: string, pattern: '^evt_[a-f0-9]{32}$'}
        type: {type: string, const: call.outcome.finalized}
        schema_version: {type: integer, const: 1}
        occurred_at: {type: string, format: date-time}
        data:
          type: object
          required:
            - import_id
            - contact
            - case
            - call
            - resolution
          properties:
            import_id: {type: string}
            contact:
              type: object
              properties:
                external_ref: {type: string}
              required:
                - external_ref
            case:
              type: object
              properties:
                external_ref: {type: string}
                invoice_number: {type: string}
                state:
                  type: string
                  description: State at finalization, e.g. PAYMENT_PROMISED, PLAN_ACTIVE, PAID. A state or summary
                    is not proof of a booked payment.
                outstanding_amount_cents: {type: integer}
                currency: {type: string}
                disputed: {type: boolean}
            call:
              type: object
              required:
                - attempt_id
                - connected_duration_seconds
                - human_review_required
              properties:
                attempt_id: {type: string}
                started_at: {type: string, format: date-time}
                connected_at: {type: string, format: date-time}
                ended_at: {type: string, format: date-time}
                connected_duration_seconds: {type: integer, minimum: 0}
                disposition: {type: string}
                outcome: {type: string}
                termination_stage: {type: string}
                summary:
                  type: string
                  description: Optional short call summary after successful post-call processing. May be absent when unavailable. Immutable event-time text; later corrections do not update this event.
                human_review_required: {type: boolean}
            resolution: {$ref: '#/components/schemas/Resolution'}
      description: Immutable snapshot after successful post-call finalization. Only attempts directly linked to
        an enrollment imported with this API key are eligible. Not a complete organization call/payment export.
        Unknown response fields and future status strings should be tolerated.
      examples:
        - id: evt_0123456789abcdef0123456789abcdef
          type: call.outcome.finalized
          schema_version: 1
          occurred_at: '2026-10-30T10:01:10.000Z'
          data:
            import_id: '123'
            contact: {external_ref: debtor-0042}
            case:
              external_ref: claim-1042
              invoice_number: RE-1042
              state: PAYMENT_PROMISED
              outstanding_amount_cents: 15000
              currency: EUR
              disputed: false
            call:
              attempt_id: '456'
              started_at: '2026-10-30T10:00:00.000Z'
              connected_at: '2026-10-30T10:00:05.000Z'
              ended_at: '2026-10-30T10:01:05.000Z'
              connected_duration_seconds: 60
              summary: Zahlung bis zum 3. November zugesagt.
              human_review_required: false
            resolution:
              payment_commitment: {amount_cents: 15000, currency: EUR, promised_date: '2026-11-03', status: promised}
    EnforcementTitle:
      type: object
      required:
        - status
        - type
      properties:
        status: {type: string, const: titled}
        type:
          type: string
          enum:
            - unknown
            - judgment
            - enforcement_order
            - other
        court:
          type:
            - string
            - 'null'
          minLength: 1
          maxLength: 200
        date:
          type:
            - string
            - 'null'
          format: date
        reference:
          type:
            - string
            - 'null'
          minLength: 1
          maxLength: 160
      additionalProperties: false
      description: Restricted organization-specific extension, not generally enabled. Importing metadata does not
        prove title-copy availability or dispatch. Non-enabled organizations receive 403 title_profile_not_enabled.
    CommunicationProfile:
      type: object
      required:
        - id
        - name
        - display_name
        - version
      properties:
        id:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Opaque numeric identifier serialized as a string; do not infer other resource IDs.
        name: {type: string}
        display_name: {type: string}
        version: {type: integer, minimum: 1}
    CommunicationProfileList:
      type: object
      required:
        - profiles
      properties:
        profiles:
          type: array
          items: {$ref: '#/components/schemas/CommunicationProfile'}
      examples:
        - profiles:
            - id: '42'
              name: Standard
              display_name: Beispiel Forderungsservice
              version: 3
    PaymentCommitment:
      type: object
      required:
        - amount_cents
      properties:
        amount_cents: {type: integer, minimum: 1}
        currency: {type: string}
        promised_date: {type: string, format: date}
        status:
          type: string
          description: Snapshot status, e.g. pending_sms, promised, paid, overdue or cancelled. Treat as extensible.
    PaymentPlan:
      type: object
      required:
        - id
        - discount_pct
      properties:
        id:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Opaque numeric identifier serialized as a string; do not infer other resource IDs.
        installments: {type: integer}
        installment_cents: {type: integer}
        first_due_date: {type: string, format: date}
        total_cents: {type: integer}
        discount_pct: {type: number}
        status:
          type: string
          description: Snapshot status; pending_approval is not an approved plan. Treat as extensible.
    Callback:
      type: object
      required:
        - scheduled_at
        - mode
      properties:
        scheduled_at: {type: string, format: date-time}
        timezone: {type: string}
        status: {type: string}
        mode:
          type: string
          enum:
            - human
            - automatic
    Resolution:
      type: object
      properties:
        payment_commitment: {$ref: '#/components/schemas/PaymentCommitment'}
        payment_plan: {$ref: '#/components/schemas/PaymentPlan'}
        callback: {$ref: '#/components/schemas/Callback'}
      description: Optional agreement/callback snapshots as available at call finalization. Can be empty. No booked
        payment object or full installment schedule. Later changes do not update this event.
    IntegrationEventPage:
      type: object
      required:
        - data
        - has_more
        - next_cursor
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/IntegrationEvent'}
        has_more: {type: boolean}
        next_cursor:
          type:
            - string
            - 'null'
          description: Opaque continuation token only when has_more=true. Terminal pages return null, including
            nonempty pages. Keep the request cursor for repeated polling and deduplicate event IDs.
      examples:
        - data:
            - id: evt_0123456789abcdef0123456789abcdef
              type: call.outcome.finalized
              schema_version: 1
              occurred_at: '2026-10-30T10:01:10.000Z'
              data:
                import_id: '123'
                contact: {external_ref: debtor-0042}
                case:
                  external_ref: claim-1042
                  invoice_number: RE-1042
                  state: PAYMENT_PROMISED
                  outstanding_amount_cents: 15000
                  currency: EUR
                  disputed: false
                call:
                  attempt_id: '456'
                  started_at: '2026-10-30T10:00:00.000Z'
                  connected_at: '2026-10-30T10:00:05.000Z'
                  ended_at: '2026-10-30T10:01:05.000Z'
                  connected_duration_seconds: 60
                  summary: Zahlung bis zum 3. November zugesagt.
                  human_review_required: false
                resolution:
                  payment_commitment: {amount_cents: 15000, currency: EUR, promised_date: '2026-11-03', status: promised}
          has_more: false
          next_cursor: null
    WebhookEndpointInput:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          example: https://erp.example.org/rescova/events
          description: Public HTTPS on port 443, no credentials or fragment. Private/non-public DNS addresses are
            rejected; redirects are not followed.
        events:
          type: array
          minItems: 1
          maxItems: 1
          items:
            type: string
            enum:
              - call.outcome.finalized
          default:
            - call.outcome.finalized
      additionalProperties: false
    WebhookEndpoint:
      type: object
      required:
        - id
        - url
        - events
        - enabled
        - created_at
        - updated_at
      properties:
        id:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Opaque numeric identifier serialized as a string; do not infer other resource IDs.
        url: {type: string, format: uri}
        events:
          type: array
          items:
            type: string
            enum:
              - call.outcome.finalized
        enabled: {type: boolean}
        created_at: {type: string, format: date-time}
        updated_at: {type: string, format: date-time}
    WebhookEndpointCreated:
      type: object
      required:
        - id
        - url
        - events
        - enabled
        - created_at
        - updated_at
        - signing_secret
        - reactivated
      properties:
        id:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Opaque numeric identifier serialized as a string; do not infer other resource IDs.
        url: {type: string, format: uri}
        events:
          type: array
          items:
            type: string
            enum:
              - call.outcome.finalized
        enabled: {type: boolean}
        created_at: {type: string, format: date-time}
        updated_at: {type: string, format: date-time}
        signing_secret:
          type: string
          description: One-time UTF-8 HMAC key including whsec_ prefix; store securely.
        reactivated: {type: boolean}
    WebhookEndpointRotated:
      type: object
      required:
        - id
        - url
        - events
        - enabled
        - updated_at
        - signing_secret
      properties:
        id:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Opaque numeric identifier serialized as a string; do not infer other resource IDs.
        url: {type: string, format: uri}
        events:
          type: array
          items:
            type: string
            enum:
              - call.outcome.finalized
        enabled: {type: boolean}
        updated_at: {type: string, format: date-time}
        signing_secret:
          type: string
          description: One-time UTF-8 HMAC key including whsec_ prefix; store securely.
    WebhookEndpointListItem:
      type: object
      required:
        - id
        - url
        - events
        - enabled
        - created_at
        - updated_at
        - last_delivery_status
        - last_response_status
        - last_delivery_at
      properties:
        id:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Opaque numeric identifier serialized as a string; do not infer other resource IDs.
        url: {type: string, format: uri}
        events:
          type: array
          items:
            type: string
            enum:
              - call.outcome.finalized
        enabled: {type: boolean}
        created_at: {type: string, format: date-time}
        updated_at: {type: string, format: date-time}
        last_delivery_status:
          type:
            - string
            - 'null'
          description: Latest created delivery status; not a full delivery history.
        last_response_status:
          type:
            - integer
            - 'null'
        last_delivery_at:
          type:
            - string
            - 'null'
          format: date-time
    WebhookEndpointList:
      type: object
      required:
        - data
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/WebhookEndpointListItem'}
    CampaignStartRequest:
      type: object
      properties:
        confirm: {type: boolean, const: true}
      required:
        - confirm
      additionalProperties: false
    CampaignStartReceipt:
      type: object
      properties:
        campaign_id: {type: string, pattern: '^[1-9][0-9]{0,18}$'}
        import_id: {type: string, pattern: '^[1-9][0-9]{0,18}$'}
        released_contacts: {type: integer, minimum: 0}
        blocked_contacts: {type: integer, minimum: 0}
        blocked_reasons:
          type: object
          additionalProperties: {type: integer, minimum: 1}
        organization_state:
          type: string
          enum:
            - running
            - paused
        replayed: {type: boolean}
      required:
        - campaign_id
        - import_id
        - released_contacts
        - blocked_contacts
        - blocked_reasons
        - organization_state
        - replayed
      examples:
        - campaign_id: '123'
          import_id: '123'
          released_contacts: 1
          blocked_contacts: 0
          blocked_reasons: {}
          organization_state: running
          replayed: false
    PaymentReceiptRequest:
      type: object
      properties:
        external_payment_ref: {type: string, minLength: 1, maxLength: 160}
        case_external_ref: {type: string, minLength: 1, maxLength: 160}
        amount_cents: {type: integer, minimum: 1, maximum: 2147483647}
        currency: {type: string, const: EUR}
        paid_date:
          type: string
          format: date
          description: Confirmed payment booking date; must not be in the future.
        note: {type: string, maxLength: 500}
        payment_plan_id:
          type: string
          pattern: ^[1-9][0-9]{0,9}$
          description: Existing plan ID; maximum 2147483647. Must be paired with installment_number.
        installment_number: {type: integer, minimum: 1, maximum: 2147483647}
      required:
        - external_payment_ref
        - case_external_ref
        - amount_cents
        - currency
        - paid_date
      additionalProperties: false
      dependentRequired:
        payment_plan_id:
          - installment_number
        installment_number:
          - payment_plan_id
      examples:
        - external_payment_ref: bank-transaction-4711
          case_external_ref: claim-1042
          amount_cents: 5000
          currency: EUR
          paid_date: '2026-10-31'
          note: Zahlung aus dem ERP bestätigt
    PaymentReceipt:
      type: object
      properties:
        id: {type: string, pattern: '^pay_[a-f0-9]{32}$'}
        external_payment_ref: {type: string}
        case_external_ref: {type: string}
        amount_cents: {type: integer, minimum: 1}
        currency: {type: string, const: EUR}
        paid_date: {type: string, format: date}
        payment_plan_id:
          anyOf:
            - {type: string, pattern: '^[1-9][0-9]{0,18}$'}
            - {type: 'null'}
        installment_number:
          anyOf:
            - {type: integer, minimum: 1}
            - {type: 'null'}
        outstanding_amount_cents: {type: integer, minimum: 0}
        case_state: {type: string}
        recorded_at: {type: string, format: date-time}
      required:
        - id
        - external_payment_ref
        - case_external_ref
        - amount_cents
        - currency
        - paid_date
        - payment_plan_id
        - installment_number
        - outstanding_amount_cents
        - case_state
        - recorded_at
      description: Immutable booking-time receipt. For current balances/agreement status read GET /v1/cases/{case_external_ref}.
      examples:
        - id: pay_0123456789abcdef0123456789abcdef
          external_payment_ref: bank-transaction-4711
          case_external_ref: claim-1042
          amount_cents: 5000
          currency: EUR
          paid_date: '2026-10-31'
          payment_plan_id: null
          installment_number: null
          outstanding_amount_cents: 10000
          case_state: CONTACTABLE
          recorded_at: '2026-10-31T10:15:00.000Z'
    PaymentReceiptResult:
      allOf:
        - {$ref: '#/components/schemas/PaymentReceipt'}
        - type: object
          properties:
            replayed: {type: boolean}
          required:
            - replayed
      examples:
        - id: pay_0123456789abcdef0123456789abcdef
          external_payment_ref: bank-transaction-4711
          case_external_ref: claim-1042
          amount_cents: 5000
          currency: EUR
          paid_date: '2026-10-31'
          payment_plan_id: null
          installment_number: null
          outstanding_amount_cents: 10000
          case_state: CONTACTABLE
          recorded_at: '2026-10-31T10:15:00.000Z'
          replayed: false
    ContactSnapshot:
      type: object
      properties:
        contact_external_ref: {type: string}
        name: {type: string}
        phone_e164: {type: string, pattern: '^\+[1-9][0-9]{6,14}$'}
        email:
          anyOf:
            - {type: string, format: email}
            - {type: 'null'}
        version:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Strong version token serialized as a string; use unchanged as expected_version or quoted
            If-Match.
        updated_at: {type: string, format: date-time}
      required:
        - contact_external_ref
        - name
        - phone_e164
        - email
        - version
        - updated_at
      examples:
        - contact_external_ref: debtor-0042
          name: Erika Beispiel
          phone_e164: '+4917612345678'
          email: erika@example.org
          version: '3'
          updated_at: '2026-10-31T10:00:00Z'
    InstallmentSnapshot:
      type: object
      properties:
        installment_number: {type: integer, minimum: 1}
        amount_cents: {type: integer}
        due_date: {type: string, format: date}
        claim_reduction_cents:
          anyOf:
            - {type: integer}
            - {type: 'null'}
        payment_id:
          description: Internal ledger reference, not the pay_ receipt ID accepted by GET /v1/payments/{payment_id}.
          anyOf:
            - {type: string, pattern: '^[1-9][0-9]{0,18}$'}
            - {type: 'null'}
        paid: {type: boolean}
        paid_at:
          anyOf:
            - {type: string, format: date-time}
            - {type: 'null'}
      required:
        - installment_number
        - amount_cents
        - due_date
        - claim_reduction_cents
        - payment_id
        - paid
        - paid_at
    CurrentPaymentPlan:
      type: object
      properties:
        id: {type: string, pattern: '^[1-9][0-9]{0,18}$'}
        status: {type: string}
        installments: {type: integer}
        installment_cents: {type: integer}
        first_due_date: {type: string, format: date}
        total_cents: {type: integer}
        discount_pct: {type: number}
        fee_cents: {type: integer}
        fee_allocation: {type: string}
        receipt_base_cents:
          anyOf:
            - {type: integer}
            - {type: 'null'}
        needs_reconciliation: {type: boolean}
        schedule_complete: {type: boolean}
        installment_schedule:
          type: array
          maxItems: 100
          items: {$ref: '#/components/schemas/InstallmentSnapshot'}
      required:
        - id
        - status
        - installments
        - installment_cents
        - first_due_date
        - total_cents
        - discount_pct
        - fee_cents
        - fee_allocation
        - receipt_base_cents
        - needs_reconciliation
        - schedule_complete
        - installment_schedule
      description: Latest live plan, otherwise latest historical plan. Do not infer missing rates if schedule_complete=false
        or book automatically when needs_reconciliation=true.
    CurrentPaymentCommitment:
      description: Latest live commitment, otherwise latest historical commitment. Check status; presence alone does not mean an active promise or a booked payment.
      type: object
      properties:
        id: {type: string, pattern: '^[1-9][0-9]{0,18}$'}
        amount_cents: {type: integer}
        currency: {type: string}
        promised_date: {type: string, format: date}
        status: {type: string}
        commitment_kind:
          anyOf:
            - {type: string}
            - {type: 'null'}
      required:
        - id
        - amount_cents
        - currency
        - promised_date
        - status
        - commitment_kind
    CaseSnapshot:
      type: object
      properties:
        case_external_ref: {type: string}
        contact_external_ref: {type: string}
        invoice_number: {type: string}
        due_date: {type: string, format: date}
        file_number:
          anyOf:
            - {type: string}
            - {type: 'null'}
        sub_file_number:
          anyOf:
            - {type: string}
            - {type: 'null'}
        creditor_name:
          anyOf:
            - {type: string}
            - {type: 'null'}
        note:
          anyOf:
            - {type: string}
            - {type: 'null'}
        original_amount_cents: {type: integer}
        outstanding_amount_cents: {type: integer}
        currency: {type: string}
        disputed: {type: boolean}
        state: {type: string}
        version:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Metadata correction token serialized as a string; use unchanged as expected_version or quoted
            If-Match. This is not a revision of joined agreements or case state.
        updated_at: {type: string, format: date-time}
        payment_plan:
          anyOf:
            - {$ref: '#/components/schemas/CurrentPaymentPlan'}
            - {type: 'null'}
        payment_commitment:
          anyOf:
            - {$ref: '#/components/schemas/CurrentPaymentCommitment'}
            - {type: 'null'}
      required:
        - case_external_ref
        - contact_external_ref
        - invoice_number
        - due_date
        - file_number
        - sub_file_number
        - creditor_name
        - note
        - original_amount_cents
        - outstanding_amount_cents
        - currency
        - disputed
        - state
        - version
        - updated_at
        - payment_plan
        - payment_commitment
      examples:
        - case_external_ref: claim-1042
          contact_external_ref: debtor-0042
          invoice_number: RE-1042
          due_date: '2026-09-30'
          file_number: AZ-1042
          sub_file_number: null
          creditor_name: Beispielhandel GmbH
          note: null
          original_amount_cents: 15000
          outstanding_amount_cents: 10000
          currency: EUR
          disputed: false
          state: CONTACTABLE
          version: '5'
          updated_at: '2026-10-31T10:15:00Z'
          payment_plan: null
          payment_commitment: null
    ContactPatch:
      type: object
      properties:
        expected_version:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Strong version token serialized as a string; use unchanged as expected_version or quoted
            If-Match.
        name: {type: string, minLength: 1, maxLength: 200}
        email:
          anyOf:
            - {type: string, format: email, maxLength: 254}
            - {const: ''}
            - {type: 'null'}
        phone_e164:
          type: string
          pattern: ^\+[1-9][0-9]{6,14}$
          description: An identical normalized phone is accepted. Actual changes return 409 phone_change_requires_manual_review;
            number/consent migration is not automatic.
      additionalProperties: false
      anyOf:
        - required:
            - name
        - required:
            - email
        - required:
            - phone_e164
    ContactPatchResult:
      allOf:
        - {$ref: '#/components/schemas/ContactSnapshot'}
        - type: object
          properties:
            changed: {type: boolean}
            calling_release_required: {type: boolean}
          required:
            - changed
            - calling_release_required
    CasePatch:
      type: object
      properties:
        expected_version:
          type: string
          pattern: ^[1-9][0-9]{0,18}$
          description: Strong version token serialized as a string; use unchanged as expected_version or quoted
            If-Match.
        invoice_number: {type: string, minLength: 1, maxLength: 160}
        due_date: {type: string, format: date}
        file_number:
          type:
            - string
            - 'null'
          maxLength: 160
        sub_file_number:
          type:
            - string
            - 'null'
          maxLength: 160
        creditor_name:
          type: string
          minLength: 1
          x-normalized-maxLength: 200
          description: Valid original creditor name; up to 200 Unicode codepoints after NFC and whitespace normalization. Explicitly clearing this field is rejected.
        note:
          type:
            - string
            - 'null'
          maxLength: 5000
      additionalProperties: false
      anyOf:
        - required:
            - invoice_number
        - required:
            - due_date
        - required:
            - file_number
        - required:
            - sub_file_number
        - required:
            - creditor_name
        - required:
            - note
    CasePatchResult:
      allOf:
        - {$ref: '#/components/schemas/CaseSnapshot'}
        - type: object
          properties:
            changed: {type: boolean}
            calling_release_required: {type: boolean}
          required:
            - changed
            - calling_release_required
externalDocs:
  description: Customer integration handbook
  url: https://app.rescova.de/docs
tags:
  - name: Imports
    description: Profiles and inactive debtor/claim batches.
  - name: Events
    description: Immutable finalized-call snapshots, not a payment ledger.
  - name: Webhooks
    description: API-key-scoped public HTTPS destinations.
  - name: Records
    description: Own imported contacts/cases, optimistic versions and current payment agreements.
  - name: Payments
    description: Confirmed ledger receipts, distinct from promises.
  - name: Campaigns
    description: Named import batches and explicit scoped scheduler release.
webhooks:
  callOutcomeFinalized:
    post:
      operationId: receiveCallOutcome
      tags:
        - Events
      summary: Receive an immutable finalized-call outcome at your registered URL
      security: []
      description: |
        Sent to the customer's registered endpoint, not an endpoint on the Rescova API server. Authentication is by HMAC, not by API key. Signature: v1=hex(HMAC-SHA256(signing_secret, timestamp + "." + exact_raw_request_body)), using the full UTF-8 secret including whsec_. Compare in constant time before processing JSON. Validate timestamp freshness (recommended +/- 5 minutes) and matching event ID/type in headers and body. Deduplicate event IDs.
        Durably accept and return any 2xx within the 10-second request timeout. Delivery may be repeated or out of order. Network/timeouts, 408/425/429 and 5xx normally allow up to 8 attempts with planned delays of 60/300/1800/7200/21600/86400/172800 seconds. Recovery after worker interruption can cause additional attempts. Other 4xx and 3xx are permanent failures; redirects are not followed. Use the pull feed to recover available missing events.
      parameters:
        - in: header
          name: X-Rescova-Event-Id
          required: true
          schema: {type: string, pattern: '^evt_[a-f0-9]{32}$'}
        - in: header
          name: X-Rescova-Event-Type
          required: true
          schema: {type: string, const: call.outcome.finalized}
        - in: header
          name: X-Rescova-Timestamp
          required: true
          schema:
            type: string
            pattern: ^[0-9]+$
            description: Unix seconds
        - in: header
          name: X-Rescova-Signature
          required: true
          schema: {type: string, pattern: '^v1=[a-f0-9]{64}$'}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IntegrationEvent'}
      responses:
        2XX:
          description: Event durably accepted by receiver; processing can continue asynchronously.
