# Partner Keys (/partner-keys)

Partner Keys let an approved organization create and manage natural-person residency applications over REST. Each `pk-...` key belongs to one Partner Integration and can access only that integration's applications.



The actors have separate responsibilities: the partner manages application data, private proof uploads, payment, status, and submission. The authenticated applicant personally accepts the Agreement of Coexistence (AOC) and completes Veriff inside eProspera. Partners cannot submit a signature, start Veriff, receive a Veriff URL, or read Veriff documents or decisions through this API.

## Base URL [#base-url]

| Environment | Origin                                 |
| ----------- | -------------------------------------- |
| Production  | `https://portal.eprospera.com`         |
| Staging     | `https://staging-portal.eprospera.com` |

## Provisioning and key lifecycle [#provisioning-and-key-lifecycle]

Partner integrations and keys are issued by eProspera administrators. They are not self-service.

* The raw key is displayed once. eProspera stores only its SHA-256 hash.
* Keys expire after 90 days.
* Issuing a replacement key shortens older active keys to a maximum seven-day overlap.
* A key can be revoked immediately. Integrations can be suspended or revoked independently.
* Authentication is rejected when the key is expired or revoked, the integration is not active, or its legal entity is dissolved.
* Redirect and checkout return URLs must use an HTTPS origin approved on the integration.

Send the key on every request:

```text
Authorization: Bearer pk-...
```

## Scopes [#scopes]

| Scope                               | Capability                                                        |
| ----------------------------------- | ----------------------------------------------------------------- |
| `partner:person.application.read`   | List and read this integration's applications and private uploads |
| `partner:person.application.create` | Create applications and upload proof of address                   |
| `partner:person.application.update` | Update Draft applications and upload proof of address             |
| `partner:person.application.pay`    | Create checkout sessions and apply voucher payments               |
| `partner:person.application.submit` | Submit a completed application                                    |

There is no API scope for signing the Agreement of Coexistence. Applicant consent always occurs on an eProspera-hosted page.

## Endpoints [#endpoints]

| Method  | Path                                                           | Required scope                                      |
| ------- | -------------------------------------------------------------- | --------------------------------------------------- |
| `POST`  | `/api/v1/partner/residency_applications`                       | `partner:person.application.create`                 |
| `GET`   | `/api/v1/partner/residency_applications`                       | `partner:person.application.read`                   |
| `GET`   | `/api/v1/partner/residency_applications/{id}`                  | `partner:person.application.read`                   |
| `PATCH` | `/api/v1/partner/residency_applications/{id}`                  | `partner:person.application.update`                 |
| `POST`  | `/api/v1/uploads/proof_of_address`                             | `create` or `update`                                |
| `GET`   | `/api/v1/uploads/proof_of_address/{uploadId}`                  | `read`, `create`, or `update`                       |
| `POST`  | `/api/v1/partner/residency_applications/{id}/checkout_session` | `partner:person.application.pay`                    |
| `POST`  | `/api/v1/partner/residency_applications/{id}/pay/voucher`      | `partner:person.application.pay`                    |
| `POST`  | `/api/v1/partner/residency_applications/{id}/pay/coupon`       | `partner:person.application.pay` (deprecated alias) |
| `POST`  | `/api/v1/partner/residency_applications/{id}/submit`           | `partner:person.application.submit`                 |

Cross-partner reads and mutations return `404` so the existence of another integration's resources is not disclosed.

## Secure application flow [#secure-application-flow]

### Partner and applicant responsibilities [#partner-and-applicant-responsibilities]

| Stage                 | Partner through the Partner API                                                                  | Applicant inside eProspera                                         |
| --------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| Application           | Creates the Draft and updates data using `expectedVersion`                                       | —                                                                  |
| Proof and payment     | Uploads private proof of address and completes checkout or voucher payment                       | —                                                                  |
| Account access        | Reads `applicantPortalAccess` status only; never receives the claim token                        | Claims the account from the 24-hour email and logs in              |
| AOC acceptance        | Fetches and delivers the current `agreementUrl`; **the partner cannot sign**                     | Authenticates as the application owner and signs only on eProspera |
| Veriff                | Reads `verificationRequired` and `submitReady` only; **cannot initiate Veriff or view its data** | Starts and completes Veriff in the authenticated portal            |
| Submission and status | Submits when `submitReady` is `true`, then polls application status                              | —                                                                  |

1. Create a Draft with `POST /partner/residency_applications` and a unique `Idempotency-Key`. eProspera sends the applicant a single-use account-claim link that expires after 24 hours.
2. Choose one proof path: upload a document using the new application ID, or send `applicationData.personalDetails.proofOfAddressSwornStatement` inline on create/PATCH.
3. For the upload path only, attach the returned opaque `proofOfAddressUploadId` with `PATCH /partner/residency_applications/{id}` and the latest `expectedVersion`. The sworn-statement path skips upload and attachment.
4. Pay with a checkout session or voucher. Each payment request requires its own `Idempotency-Key`.
5. Instruct the applicant to use the claim email and log in to eProspera. Fetch the application again after the final PATCH, then send that applicant the current `nextSteps.agreementUrl`.
6. The agreement URL contains a 30-minute, single-use token. eProspera requires the applicant's Auth0 login and verifies that the logged-in subject owns the application. During login, the bearer is held server-side behind a short-lived, single-use opaque continuation rather than copied into login or `returnTo` URLs. A different user receives `404`, and admin impersonation is not accepted as applicant identity.
7. After acceptance, fetch the application again: acceptance consumes the link and increments `version`. Use the refreshed version for any later PATCH.
8. The logged-in applicant starts and completes Veriff in the authenticated eProspera portal. The partner sees only `applicantPortalAccess`, `nextSteps.verificationRequired`, and `nextSteps.submitReady`.
9. Submit the application after `nextSteps.submitReady` becomes `true`.

The hosted agreement page selects the applicable AOC server-side. Acceptance records the agreement ID and version, SHA-256 hashes of its content and the legally relevant application data, the authenticated applicant's Auth0 subject, signer name, signature image, timestamp, IP address, user agent, and access-token ID. A legally relevant PATCH invalidates the current acceptance and returns a new `agreementUrl`; submission also rejects consent if application data changed through another path. Bearer-only acceptances without a matching authenticated signer are not valid for submission.

Submission requires current agreement acceptance, proof of address, a paid invoice, and approved identity verification. AOC acceptance and payment endpoints never submit the application; submission occurs only through `/submit` with `partner:person.application.submit`.

## Create and update contracts [#create-and-update-contracts]

Create requests require `Idempotency-Key`:

```bash
curl -X POST https://portal.eprospera.com/api/v1/partner/residency_applications \
  -H "Authorization: Bearer pk-REDACTED" \
  -H "Idempotency-Key: application-ada-20260716" \
  -H "Content-Type: application/json" \
  -d '{
    "applicationData": {
      "residencyType": "e_resident",
      "personalDetails": {
        "givenName": "Ada",
        "surname": "Lovelace",
        "email": "applicant@example.com",
        "dateOfBirth": "1990-12-10",
        "sex": "female",
        "countryOfBirth": "GB",
        "citizenships": ["GB"],
        "alternateNamesAttestation": false,
        "criminalBackgroundAttestation": true,
        "address": {
          "line1": "123 Main St",
          "city": "London",
          "postalCode": "SW1A 1AA",
          "country": "GB"
        },
        "phoneNumber": "+447700900123"
      }
    },
    "redirectUrl": "https://partner.example/return"
  }'
```

An update sends the full application DTO plus the response's latest `version`:

```json
{
  "expectedVersion": 3,
  "applicationData": {
    "residencyType": "e_resident",
    "personalDetails": {
      "givenName": "Ada",
      "surname": "Lovelace",
      "email": "applicant@example.com",
      "dateOfBirth": "1990-12-10",
      "sex": "female",
      "countryOfBirth": "GB",
      "citizenships": ["GB"],
      "alternateNamesAttestation": false,
      "criminalBackgroundAttestation": true,
      "proofOfAddressUploadId": "12345678-abcd-4abc-8abc-123456789012",
      "address": {
        "line1": "123 Main St",
        "city": "London",
        "postalCode": "SW1A 1AA",
        "country": "GB"
      },
      "phoneNumber": "+447700900123"
    }
  }
}
```

Once an invoice is paid, the residency type and applicant email are locked. A stale `expectedVersion` returns `409` with `currentVersion`.

## Response fields [#response-fields]

Application responses include:

| Field                                                    | Meaning                                                          |
| -------------------------------------------------------- | ---------------------------------------------------------------- |
| `version`                                                | Integer used as the next PATCH `expectedVersion`                 |
| `applicationData`                                        | Curated application data                                         |
| `applicationData.personalDetails.proofOfAddressUploadId` | Opaque private-upload ID, when an upload is attached             |
| `applicantPortalAccess`                                  | Claim/login metadata; never contains credentials or a Veriff URL |
| `nextSteps.agreementUrl`                                 | Hosted consent URL while current acceptance is required          |
| `nextSteps.proofOfAddressRequired`                       | Whether proof is still missing                                   |
| `nextSteps.paymentRequired`                              | Whether payment is still required                                |
| `nextSteps.verificationRequired`                         | Whether portal identity verification is still required           |
| `nextSteps.verificationInstructions`                     | Applicant-facing portal verification guidance, when required     |
| `nextSteps.submitReady`                                  | Whether all submission prerequisites currently pass              |

`nextSteps.agreementUrl` is ephemeral. Fetch the application after every PATCH and after AOC acceptance. Do not cache or reuse an older URL or `version`.

`applicantPortalAccess.claimLinkSent` and `claimLinkSentAt` describe whether the current mutation enqueued a claim email. GET requests do not send email and therefore return `false` and `null` for those fields.

Application lists use cursor pagination:

```text
GET /api/v1/partner/residency_applications?limit=50&cursor=<application-id>
```

`limit` defaults to 50 and cannot exceed 100. The response contains `pagination.nextCursor`, or `null` on the final page.

## Idempotency [#idempotency]

Application creation, checkout creation, and voucher payment require an `Idempotency-Key` containing 8–128 URL-safe characters. Records are retained for 24 hours per integration and route.

* An identical completed request replays the stored response with `Idempotency-Replayed: true`.
* Reusing a key with different input returns `409`.
* A duplicate received while the original is processing returns `409`.

## Rate limits [#rate-limits]

Limits are enforced by both API key and integration:

| Class                  | Per key    | Per integration |
| ---------------------- | ---------- | --------------- |
| Reads                  | 120/minute | 300/minute      |
| Updates and submission | 60/minute  | 150/minute      |
| Application creation   | 10/minute  | 50/hour         |
| Uploads                | 20/minute  | 100/hour        |
| Payment and vouchers   | 5/minute   | 20/hour         |

Invalid credentials are limited to 20 attempts per minute per source IP. A throttled request returns `429` and a `Retry-After` header.

## Errors [#errors]

| Status | Meaning                                                         |
| ------ | --------------------------------------------------------------- |
| `400`  | Invalid request, disallowed redirect origin, or invalid state   |
| `401`  | Missing, invalid, expired, or revoked key; inactive integration |
| `403`  | Valid key without the required scope                            |
| `404`  | Resource missing or owned by another integration                |
| `409`  | Stale application version or idempotency conflict               |
| `429`  | Rate limit exceeded; use `Retry-After`                          |

Every successful action, denied scoped action, throttling decision, inactive-key attempt, and admin key lifecycle change is durably audited without storing raw credentials.
