# Technical Implementation Guide

*Audience: Engineers integrating with the DSR API, connecting third-party systems, or building custom fulfillment integrations*

This guide covers the DSR API, connecting pre-built vendor integrations, and how to build a custom integration (Custom DSR Task / Custom DSR Trigger Task) against a system that doesn't have a pre-built connector.

## The DSR API

### Authentication

Every request is authenticated via **HMAC-SHA256 request signing** - there is no bearer token. Sign the raw request body with your API key and send the result as the `Authorization` header:

```
Authorization: Relyance1 {hex(hmac_sha256(api_key, raw_request_body))}
```

```python
import hashlib
import hmac
import json
import requests

body = json.dumps(payload, separators=(",", ":"))  # sign the exact bytes you send
signature = hmac.new(api_key.encode(), body.encode(), hashlib.sha256).hexdigest()

response = requests.post(
    "https://api.relyance.ai/api/dsr/v2/create",
    data=body,  # send the same bytes you signed - do not re-serialize
    headers={
        "Authorization": f"Relyance1 {signature}",
        "Content-Type": "application/json",
    },
)
```

> **Common pitfall:** signing one JSON serialization and sending another (e.g., signing a dict then letting an HTTP client re-serialize it with different key ordering or whitespace) is the most common cause of a 401. Sign the exact byte string you transmit.

### Endpoints

#### Create a DSR

```
POST /api/dsr/v2/create
```

Creates a new DSR. Minimal example:

```json
{
  "type": "DSAR_TYPE_ACCESS_DATA",
  "submissionType": "DATA_SUBJECT_SUBMISSION",
  "dataSubject": {
    "type": "Customer",
    "email": "jane.doe@example.com",
    "firstName": "Jane",
    "lastName": "Doe",
    "jurisdiction": {
      "region": "US",
      "country": "United States",
      "state": "California"
    }
  }
}
```

| Response | Meaning |
|---|---|
| `201` | DSR created; response body includes the new DSR's `id` |
| `400` | Schema validation failure - see [Errors](#errors) below |
| `401` | Signature/key issue - see [Authentication](#authentication) above |

#### Get a pre-signed attachment upload URL

```
POST /api/dsr/v2/attachment/presign
```

Call this **before** `create` whenever the request needs a file attached (most commonly, an authorized-agent authorization document). Returns a pre-signed upload URL and a token you'll reference in the subsequent `create` call.

#### Bulk-import DSRs

```
POST /api/dsr/admin/import
```

Same authentication and `dataSubject`/`jurisdiction` schema as `create`, but accepts a batch - useful for migrating historical requests from a prior system rather than replaying them one at a time.

### Request schema reference

| Field | Type | Notes |
|---|---|---|
| `dataSubject.type` | string | Data subject category (e.g., `Customer`, `Employee`) |
| `dataSubject.email` / `emails` | string / array | At least one is typically required |
| `dataSubject.firstName` / `lastName` | string | |
| `dataSubject.userID` | string | |
| `dataSubject.phoneNumbers` | array | |
| `dataSubject.customFields` | array | Submission-form custom field answers |
| `dataSubject.jurisdiction.region` | string | **Required key** - must be present even if `null` |
| `dataSubject.jurisdiction.country` | string | nullable |
| `dataSubject.jurisdiction.state` | string | nullable |
| `submissionType` | enum | `DATA_SUBJECT_SUBMISSION` (default) or `AUTHORIZED_AGENT_SUBMISSION` |
| `businessEntityId` | string | Required only if your tenant runs Multi-Portal - see [Admin Configuration Guide](/docs/data-subject-requests/admin-configuration-guide/#multi-portal-multiple-business-entities) |

Two schema behaviors to design around:

- **`jurisdiction` requires all three keys present** (`region`, `country`, `state`) - `null` values are fine, but omitting the `region` key entirely returns a 400.
- **Unknown fields are rejected outright** (`extra='forbid'`) - a typo'd or deprecated field name hard-fails the request rather than being silently dropped. Validate against the current schema rather than reusing an older integration's field list.

### Errors

| Status | Cause |
|---|---|
| `401` | Bad or missing signature, wrong API key |
| `400` | Missing required jurisdiction keys, unknown fields, missing `businessEntityId` under Multi-Portal, malformed `customFields` |

### Recipe: submitting on behalf of an authorized agent

1. `POST /api/dsr/v2/attachment/presign` → get an upload URL + token.
2. `PUT` the authorization document (e.g., a signed power-of-attorney PDF) directly to that URL.
3. `POST /api/dsr/v2/create` with `submissionType: "AUTHORIZED_AGENT_SUBMISSION"` and a `customFields` entry of type `CUSTOM_DSR_FIELD_TYPE_ATTACHMENT` referencing the upload token, mapped to the workflow's authorized-agent-document field:

```json
{
  "type": "DSAR_TYPE_ACCESS_DATA",
  "submissionType": "AUTHORIZED_AGENT_SUBMISSION",
  "dataSubject": { "...": "as above" },
  "customFields": [
    {
      "type": "CUSTOM_DSR_FIELD_TYPE_ATTACHMENT",
      "fieldMapping": "AUTHORIZED_AGENT_DOCUMENTS",
      "value": "{{presign_token_from_step_1}}"
    }
  ]
}
```

This three-call pattern is proven in production for spreadsheet-driven intake pipelines (e.g., a Google Sheets + Apps Script front end that collects agent name/email/document token per row and calls the API per row) - a useful reference architecture if your own intake isn't going through the Privacy Portal directly.

## Pre-built connectors

Before building a custom integration, check whether Relyance already ships a pre-built connector for the system you need - it's the faster path and doesn't require you to know the target system's API surface yourself. A pre-built connector already knows how to search for a data subject's records in that system and either return them or delete them, wherever the system's own API supports it.

### Where to find them

Pre-built connectors live in the same place as every other Relyance integration: **Settings/Configuration → Integrations**. Browse or search the catalog for the system you want to connect - if a tile exists for it, a pre-built DSR connector is available (or can be, once DSR processing is enabled on it - see below).

### Setting one up

1. Find the vendor's tile in the Integrations catalog and click into it.
2. Click **Add Connection**, name it, and save.
3. On the connection detail page, choose one of the vendor's supported authentication types and fill in its fields. Depending on the vendor this may be an API token/app token, a JWT, a fully custom vendor-defined form, or two-legged OAuth (client-credentials style, no user redirect - typically the recommended option where a vendor offers it).
4. Enable DSR processing on the connection - most vendor connections expose a **Process DSRs** toggle once base authentication succeeds; turning it on grants the additional scopes/permissions the connector needs to fetch or delete subject data (some vendors need broader API scope for deletion than for read-only access, so this may prompt you to re-authenticate with expanded permissions).
5. Save. Once active with DSR processing enabled, the connection becomes selectable in any workflow step that supports it.

### Using it in a workflow

In the workflow's **Steps** tab, add a **Third Party Access** step (to fetch data) or a **Third Party Delete** step (to delete data) and select your connection from the dropdown. Unlike a Data Access/Delete Webhook step, there's no URL, method, headers, or body to configure - the connector already knows how to search and retrieve/delete records in that system; you're just pointing the step at an authenticated connection.

If a vendor tile isn't in the catalog, or the system is a plain database your team manages directly, see [Custom DSR Task and Custom DSR Trigger Task](#custom-dsr-task-and-custom-dsr-trigger-task) below for how to connect it yourself.

## Custom DSR Task and Custom DSR Trigger Task

These are the two building blocks for connecting a system that doesn't have a pre-built connector. They solve opposite problems:

| | Direction | Answers | Key config field |
|---|---|---|---|
| **Custom DSR Task** | Outbound - Relyance calls the target system | "How do we fetch/delete data in system X?" | `dsr_access_url` / `dsr_delete_url` |
| **Custom DSR Trigger Task** | Inbound - the target system calls Relyance | "How does system X tell Relyance a new request exists?" | `workflow_id` |

Both are configured as **connections** under a distinct integration tile in the catalog, and both share the same three authentication options (below). A connection only holds credentials + endpoint metadata - the actual request shape (URL, method, headers, body) for a Custom DSR Task is defined per-step in the workflow editor, not on the connection itself. This split means rotating a credential never requires touching every step/workflow that references it.

### Setting up a Custom DSR Task connection (credentials)

1. Go to **Settings/Configuration → Integrations** and select the **Custom DSR Task** tile.
2. Click **Add Connection**. Give it a name that identifies what it points to (e.g., `Internal Billing System - DSR`).
3. On the connection detail page, configure:
    - **Authentication** - one of the three types below.
    - **`dsr_access_url`** / **`dsr_delete_url`** *(optional)* - reference metadata for your own team's documentation; these are **not** called at runtime. The URL actually invoked at runtime is set independently on each workflow step (see below) - a common point of confusion, since it's easy to assume setting this field is sufficient on its own.
    - **Vendor association** - associate the connection with either a catalogued vendor (feeds external RoPA/vendor mapping) or a named internal service (feeds internal RoPA generation). Set one or the other, not both.
4. Save. The connection must show **active** (i.e., its credentials verify successfully) before it will appear as selectable in a workflow step.

### Setting up a Custom DSR Trigger Task connection (credentials)

1. Go to **Settings/Configuration → Integrations** and select the **Custom DSR Trigger Task** tile.
2. Click **Add Connection** and name it.
3. Configure the same **Authentication** options as above, plus the required **`workflow_id`** - the workflow this connection will feed incoming requests into. Every request that arrives through this trigger is routed to this one workflow; if you need to trigger multiple workflows from the same external system, create one Trigger Task connection per target workflow.
4. Save. Relyance will expose an inbound endpoint/credential pair for the external system to call - consult your connection detail page for the exact inbound URL and expected payload shape once the connection is active.

### Authentication options (shared by both connection types)

Pick based on what the target system supports:

**1. Custom API Integration** - a generic JSON credential blob. Set `type` to select the scheme:

```json
// API Key
{ "type": "apikey", "api_key": "..." }              // sent as X-API-Key header

// Bearer token
{ "type": "bearer", "token": "..." }                 // sent as Authorization: Bearer <token>

// HTTP Basic
{ "type": "basic", "username": "...", "password": "..." }

// HTTP Digest
{ "type": "digest", "username": "...", "password": "..." }

// OAuth2 (client credentials, specified inline rather than via the dedicated form)
{ "type": "oauth2", "client_id": "...", "client_secret": "...", "token_url": "..." }

// No auth
{ "type": "none" }
```

**2. OAuth2 (Client Credentials)** - a dedicated form (use this instead of the inline `oauth2` blob above when you don't need anything custom): `token_url`, `client_id`, `client_secret`, and a toggle for whether credentials are sent as HTTP Basic auth on the token request or in the POST body. Tokens are cached and refreshed automatically before they expire - you don't need to handle token refresh yourself.

**3. OAuth2 (Authorization Code)** - full three-legged OAuth: `auth_url`, `token_url`, `client_id`, `client_secret`, and a `scopes` list, with optional PKCE support. Use this when the target system requires a user-in-the-loop consent grant rather than a machine-to-machine credential.

### Wiring a Custom DSR Task into a workflow

Once the connection is active, it's available to any workflow. In the workflow's **Steps** tab:

1. **Add Step → Data Access Webhook** (for the access/export side of the request).
2. In the step editor:
    - **Connection** - select your Custom DSR Task connection from the dropdown (only active, DSR-capable connections are listed). Use the inline **Configure/Edit Connection** link if you need to jump back and fix credentials.
    - **URL** - the actual endpoint called when this step runs. Independent of the connection's `dsr_access_url` reference field.
    - **Request Method** - GET/POST/PUT/PATCH/DELETE.
    - **Custom Headers** - any headers your endpoint needs beyond what the connection's auth already injects (e.g., a tenant-identifying header, `Content-Type`).
    - **Body** - the request payload, including any templated/dynamic fields from the subject's data.
    - **Advanced Config** - the raw JSON editor for anything not covered by the structured fields (response-path extraction, conditional skip logic, and more) - see [Advanced Config (Step JSON) Reference](#advanced-config-step-json-reference) below.
3. **Add Step → Data Delete Webhook** and repeat, pointing at the system's deletion endpoint. You can reuse the same connection if access and delete share credentials, or select a different one if they don't.
4. Save the workflow.

At runtime, each step authenticates using its selected connection's credentials, then sends the request exactly as configured on that step.

### Wiring a Custom DSR Trigger Task

A Trigger Task connection doesn't get added as a workflow step - but it does require configuration on **both** the connection and the target workflow:

1. **On the connection** - set `workflow_id` to the workflow that should process incoming requests from this trigger (as described above).
2. **On the workflow** - open the target workflow's **Overview** tab and add an entry under **Triggers**. Select the Custom DSR Trigger Task connection you created, then configure its **Rules** (a JSON editor) - this is what determines how an incoming payload is matched/mapped before the workflow accepts it as a new DSR. A workflow won't actually start processing requests from the connection until this Trigger entry exists on the workflow side, even though the connection already points at the workflow via `workflow_id`.

Once both sides are configured, the external system calls Relyance's inbound endpoint whenever it needs to start a request, authenticating with the credentials configured on the connection. If the external system's payload doesn't map cleanly to Relyance's expected request shape, use the Rules JSON to bridge the difference, or add a transformation layer (middleware) on the external system's side before it calls Relyance.

**Example Rules configuration** - mapping an inbound ticketing-system payload to a new Access DSR for a Customer in the US:

```json
{
  "match": {
    "requestType": "data_access"
  },
  "map": {
    "type": "DSAR_TYPE_ACCESS_DATA",
    "dataSubject.type": "Customer",
    "dataSubject.email": "$.payload.customer_email",
    "dataSubject.firstName": "$.payload.customer_first_name",
    "dataSubject.lastName": "$.payload.customer_last_name",
    "dataSubject.jurisdiction.region": "$.payload.region",
    "dataSubject.jurisdiction.country": "$.payload.country",
    "dataSubject.jurisdiction.state": "$.payload.state"
  }
}
```

Treat this as illustrative rather than a literal schema reference - the exact fields your Rules JSON can reference depend on the shape of the payload your external system actually sends, and the Rules editor's field picker will reflect what's available for your specific connection. The general pattern holds regardless: a **match** condition decides whether an inbound payload should create a DSR at all, and a **map** decides how its fields populate the resulting request's `dataSubject`/`jurisdiction` (the same schema used by the [DSR API](#the-dsr-api)).

## Advanced Config (Step JSON) Reference

Every workflow step type exposes an **Advanced Config** control ("Edit JSON") in its editor, opening a raw JSON editor bound to the step's rule configuration (`ruleConfig` over GraphQL; `rule_config` on the backend `StepConfig` model). There's no schema enforced in the editor itself - it's a plain string field - so this reference documents the real, current schema (`RuleConfigV2`) so you know exactly what you're editing. When you open a brand-new step, the platform pre-populates this field with a server-generated default for that step type rather than an empty object.

### Top-level fields

| JSON key | Type | Default | Controls |
|---|---|---|---|
| `version` | int | `2` | Schema version |
| `stepTimeout` | int (seconds) | `300` | How long the step waits before it's considered timed out and marked `ERROR` |
| `retryAfter` | int (seconds) | `3600` | Delay before an automatic retry fires after a failure |
| `retryAttempts` | int | `3` | Number of retries before the step gives up and fires `onNoMoreRetries` |
| `asyncCallback` | bool | `false` | Marks the step as expecting an asynchronous webhook callback rather than completing on the initial response - see [Async / callback steps](#async-callback-steps) |
| `pathToSubjectData` | JMESPath string | `"body"` | Where in the step's **synchronous API response** the subject-data payload lives |
| `pathToCallbackData` | JMESPath string | `"data"` | Where in an **async callback payload** the subject-data payload lives |
| `pathToSourceName` | JMESPath string | `"source_name"` | Where in a response/callback to find a value used as that result's "Source" label |
| `pathToResourceName` | JMESPath string | `"resource_name"` | Where in a response/callback to find a value used as that result's "Resource" label |
| `staticSourceName` | string \| `null` | `null` | Hard-coded Source label, overriding `pathToSourceName` |
| `staticResourceName` | string \| `null` | `null` | Hard-coded Resource label, overriding `pathToResourceName` |
| `subjectMap` | object \| `null` | `null` | Key/value remapping of subject fields based on values found in the response |
| `onStart` | array of [ResponseEvaluator](#the-evaluator-condition-and-action-objects) | `[]` | Checked **before** the step runs - typically used to skip based on an earlier step's outcome |
| `onRequest` | array of ResponseEvaluator | vendor default (status-code checkpoints) | Checked against the **synchronous** API response |
| `onCallback` | array of ResponseEvaluator | vendor default | Checked against each **async callback** payload |
| `onTimeout` / `onSuccess` / `onFailed` / `onError` / `onSkipped` / `onCompleted` / `onNoMoreRetries` | array of ResponseEvaluator | `[]` | Fire when the step reaches that respective outcome |

### Retry and timeout behavior

Retry count and timeout **are** genuinely configurable per step through this JSON - `retryAttempts`, `retryAfter`, and `stepTimeout` directly override the step's runtime behavior (they aren't just cosmetic):

- `stepTimeout` sets how long the step waits before its deadline passes and it's marked failed with "Step has timed out."
- On a failure, `retryAfter` sets how long the platform waits before automatically retrying, and each retry decrements a remaining-attempts counter seeded from `retryAttempts`.
- Once retries are exhausted, `onNoMoreRetries` fires (commonly used to end the step as failed and/or notify someone).

Every step type also has a **system-level default** retry count and timeout baked into its step type definition (for example, a Data Access Webhook step defaults to 3 retries / 300s, while a Third Party Access step defaults to 1 retry / 3600s, and an interactive/manually-triggered email step defaults to a 30-day timeout). Those per-type defaults are what you're overriding when you set `retryAttempts`/`retryAfter`/`stepTimeout` explicitly in Advanced Config - if you omit them, the step type's own default applies.

### Async / callback steps

Set `"asyncCallback": true` on any step that doesn't get its result back in the same request/response cycle - for example, a third-party system that receives your request and then calls back later (possibly more than once) as it processes it. With this set:

- The step enters a waiting state (surfaced as **Waiting for Third Party** in the request list) rather than completing after the initial response.
- Each `onCallback` evaluator runs against every callback payload received. Returning the `STEP.PENDING` action from an evaluator (for example, when the payload's `status` field reads `"PENDING"`) keeps the step waiting for further callbacks instead of ending it - this is how a step can process several distinct payloads over time (e.g., a document upload followed later by a verification result) without you needing to declare a fixed count up front.
- Each callback payload is tracked as its own distinct result, labeled using whatever `pathToSourceName`/`staticSourceName` (and `pathToResourceName`/`staticResourceName`) resolve to for that payload. **This is what the step editor's "Response Name" field is setting under the hood** - giving each expected callback a static, human-readable Response Name is equivalent to setting `staticSourceName` for that payload, and is what later steps/templates reference via `{{ STEPS.ID.responses.<response_name>.<field> }}` (see [Admin Configuration Guide](/docs/data-subject-requests/admin-configuration-guide/#email-templates)).
- There is no separate "expected callback count" or overall async timeout field - `stepTimeout` is the only timeout, and it's re-armed on each retry/attempt rather than tracked separately per callback.

### The evaluator, condition, and action objects

`onStart`, `onRequest`, `onCallback`, and the outcome hooks (`onSuccess`, `onFailed`, etc.) are all arrays of the same shape:

```json
{
  "condition": { "target": "status_code", "operator": "EQUAL", "value": 400, "valueType": "ANY", "onError": "FALSE", "caseSensitive": false },
  "ifTrue":  { "action": "STEP.END_STEP.FAILED",  "message": "API Failure: 400 Bad Request", "payload": {}, "origin": "WORKFLOW" },
  "ifFalse": { "action": "STEP.END_STEP.SUCCESS", "message": "", "payload": {}, "origin": "WORKFLOW" }
}
```

- **`condition.target`** - a JMESPath expression evaluated against the current context (the API response, the callback payload, or - for `onStart` - an upstream step's context, referenced as `<STEP_ID>_CONTEXT.<field>`, e.g. `"STEP_1_CONTEXT.stepState"`).
- **`condition.operator`** - a comparator, e.g. `EQUAL`, `HAS_KEYS`.
- **`condition.value`** / **`valueType`** - what to compare against, and how to cast it.
- **`condition.onError`** - what to do if evaluating the target itself errors (e.g., the path doesn't exist) - commonly `FALSE` (treat as non-match) or `RAISE`.
- **`ifTrue`** / **`ifFalse`** - the `Action` to take depending on the condition's result. Either can be omitted if there's nothing to do on that branch.
- **`action`** (on an `Action`) - the outcome to trigger, e.g. `STEP.END_STEP.FAILED`, `STEP.END_STEP.SUCCESS`, `STEP.END_STEP.SKIPPED`, or `STEP.PENDING` (keep waiting - see [Async / callback steps](#async-callback-steps) above).
- **`message`** - human-readable text that shows up in the audit log for that outcome.

### Full real example (Rule Webhook step)

```json
{
  "version": 2,
  "stepTimeout": 300,
  "retryAfter": 3600,
  "retryAttempts": 3,
  "asyncCallback": true,
  "pathToSubjectData": "body",
  "pathToCallbackData": "data",
  "pathToSourceName": "source_name",
  "pathToResourceName": "resource_name",
  "onStart": [],
  "onRequest": [
    {
      "condition": { "target": "status_code", "operator": "EQUAL", "value": 400, "valueType": "ANY", "onError": "FALSE", "caseSensitive": false },
      "ifTrue": { "action": "STEP.END_STEP.FAILED", "message": "API Failure: 400 Bad Request", "payload": {}, "origin": "WORKFLOW" }
    },
    {
      "condition": { "target": "status_code", "operator": "EQUAL", "value": 404, "valueType": "ANY", "onError": "FALSE", "caseSensitive": false },
      "ifTrue": { "action": "STEP.END_STEP.SKIPPED", "message": "No record found for this subject", "payload": {}, "origin": "WORKFLOW" }
    }
  ],
  "onCallback": [
    {
      "condition": { "target": "@", "operator": "HAS_KEYS", "value": ["data", "status"], "valueType": "ANY", "onError": "RAISE", "caseSensitive": false },
      "ifFalse": { "action": "STEP.END_STEP.FAILED", "message": "Payload missing required key(s) ['data', 'status']", "payload": {}, "origin": "WORKFLOW" }
    },
    {
      "condition": { "target": "status", "operator": "EQUAL", "value": "PENDING", "valueType": "ANY", "onError": "RAISE", "caseSensitive": false },
      "ifTrue": { "action": "STEP.PENDING", "message": "Expecting more data from service", "payload": {}, "origin": "WORKFLOW" }
    }
  ],
  "onTimeout": [], "onSuccess": [], "onFailed": [], "onError": [], "onSkipped": [], "onCompleted": [], "onNoMoreRetries": []
}
```

An `onStart` condition referencing an upstream step looks like:

```json
{
  "condition": { "target": "STEP_1_CONTEXT.stepState", "operator": "EQUAL", "value": "SKIPPED", "valueType": "ANY", "onError": "FALSE", "caseSensitive": false },
  "ifTrue": { "action": "STEP.END_STEP.SKIPPED", "message": "Previous step skipped. Skipping this step.", "payload": {}, "origin": "WORKFLOW" }
}
```

After editing Advanced Config, use **Test Step** and check the resulting step-testing audit log to confirm the JSON did what you expected before publishing the workflow.

## Troubleshooting integration connections

- **Sudden 401/403 errors from a previously-working connection** are almost always expired or rotated credentials on the target system's side (API key rotated, OAuth app re-authorized, service account disabled). Check the connection's auth configuration first before assuming a Relyance-side issue - the audit log for the affected step will show the exact HTTP status and response body returned by the target system (see [Audit Logs, Troubleshooting & Reporting](/docs/data-subject-requests/audit-logs-troubleshooting-and-reporting/#troubleshooting-a-failed-third-party-step)).
- **A field is missing from an Access response even though it's visible in the target system's UI** - not every field surfaced in a vendor's own UI is exposed through its API. This is a limitation of the target system, not Relyance; confirm with the vendor whether the field is available via API at all before assuming a configuration error.
