SCC Compliance
zplflow exposes endpoints that satisfy the obligations imposed by the EU Standard Contractual Clauses adopted under Commission Implementing Decision (EU) 2021/915. Use them to register breach notification contacts, retrieve audit events, and manage sub-processor change notifications.
Contents: Breach Contact · Audit Events · Sub-Processor Notify · Regulatory References
Overview
| Endpoint | SCC Clause | Purpose |
|---|---|---|
PUT /v1/tenants/{id}/breach-contact |
9.3 | Register the email address that must be notified of a personal data breach |
GET /v1/audit/events |
7.6 | Retrieve the log of events recorded for the authenticated tenant |
POST /v1/tenants/sub-processor/notify |
7.7 | Send sub-processor change notifications to all active tenants (administrative) |
Both endpoints are tenant-scoped. The tenant identifier is always taken from the API key authentication context; cross-tenant requests are rejected.
PUT /v1/tenants/{id}/breach-contact
Configure the email address that zplflow uses to notify the data controller of a personal data breach. The path {id} must match the tenant resolved from the API key; otherwise the request is rejected with 403 FORBIDDEN.
Idempotent: no (always overwrites the previous contact)
Content-Type: application/json
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Tenant identifier; must match the authenticated tenant |
Request Body
{
"email": "dpo@company.com",
"enabled": true
}
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | yes | RFC 5322 email address, max 254 characters |
enabled |
bool | yes | When false, suppresses outbound notifications while keeping the contact on file |
Response (200 OK)
{
"status": "breach_contact_updated",
"email": "dpo@company.com",
"enabled": true
}
Errors
| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_PARAMS |
Malformed body or invalid email address |
| 401 | UNAUTHORIZED |
Missing or invalid API key |
| 403 | FORBIDDEN |
Path {id} does not match the authenticated tenant |
Example
curl -X PUT "https://api.zplflow/v1/tenants/t-123/breach-contact" \
-H "Authorization: Bearer lb_live_xxxx" \
-H "Content-Type: application/json" \
-d '{"email":"dpo@company.com","enabled":true}'
import requests
resp = requests.put(
"https://api.zplflow/v1/tenants/t-123/breach-contact",
headers={
"Authorization": "Bearer lb_live_xxxx",
"Content-Type": "application/json",
},
json={"email": "dpo@company.com", "enabled": True},
)
print(resp.json())
# {"status": "breach_contact_updated", "email": "dpo@company.com", "enabled": True}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
body, _ := json.Marshal(map[string]interface{}{
"email": "dpo@company.com",
"enabled": true,
})
req, _ := http.NewRequest("PUT",
"https://api.zplflow/v1/tenants/t-123/breach-contact",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer lb_live_xxxx")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
fmt.Println(out)
}
<?php
$ch = curl_init("https://api.zplflow/v1/tenants/t-123/breach-contact");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT");
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"email" => "dpo@company.com",
"enabled" => true,
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer lb_live_xxxx",
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
echo curl_exec($ch);
AS400 / SQL Native (IBM i)
SELECT SYSTOOLS.HTTPPUTCLOB(
'https://api.zplflow/v1/tenants/t-123/breach-contact',
'{"email":"dpo@company.com","enabled":true}',
'{"Authorization":"Bearer lb_live_xxxx","Content-Type":"application/json"}'
) AS response
FROM SYSIBM.SYSDUMMY1;
AS400 / HTTPAPI (RPG)
dcl-s body varchar(500);
dcl-s response varchar(32000);
dcl-s rc int(10);
body = '{"email":"dpo@company.com","enabled":true}';
rc = http_req('PUT'
: 'https://api.zplflow/v1/tenants/t-123/breach-contact'
: *null
: %trim(response)
: 'Authorization: Bearer lb_live_xxxx'
: 'Content-Type: application/json'
: *null
: 30000
: %trim(body));
Note: Set
enabled: falseto suspend notifications without erasing the contact. The audit log still records every change.
GET /v1/audit/events
Retrieve the audit events recorded for the authenticated tenant. The tenant identifier is forced from the API key context; you cannot query another tenant through this endpoint.
Idempotent: yes (read-only)
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
from |
RFC3339 | - | Inclusive lower bound on timestamp |
to |
RFC3339 | - | Inclusive upper bound on timestamp |
action |
string | - | Exact-match filter on the event action field |
limit |
int | 100 | Page size, 1-1000 |
offset |
int | 0 | Skip count, >= 0 |
Response (200 OK)
{
"events": [
{
"id": "7c2b6c5e-1f4d-4c0a-9f1a-7e2b1e9d5b41",
"tenant_id": "t-123",
"actor_email": "ops@company.com",
"action": "TENANT_DELETED",
"resource_type": "tenant",
"resource_id": "t-123",
"details": "jobs_canceled=5",
"timestamp": "2026-07-14T14:05:00Z",
"ip_address": "203.0.113.42"
}
],
"meta": {
"limit": 100,
"offset": 0,
"count": 42
}
}
| Field | Type | Description |
|---|---|---|
events[].id |
string | Event identifier (UUID) |
events[].tenant_id |
string | Owning tenant |
events[].actor_email |
string | Email of the user or service that triggered the action |
events[].action |
string | Action code, e.g. TENANT_DELETED, API_KEY_CREATED |
events[].resource_type |
string | Resource category (tenant, api_key, job, …) |
events[].resource_id |
string | Identifier of the affected resource |
events[].details |
string | Free-form context (key=value pairs) |
events[].timestamp |
RFC3339 | UTC event time |
events[].ip_address |
string | Source IP of the request |
meta.count |
int | Number of events returned in the current page |
meta.limit |
int | Effective page size |
meta.offset |
int | Effective offset |
Errors
| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_PARAMS |
Invalid from, to, limit, or offset value |
| 401 | UNAUTHORIZED |
Missing or invalid API key |
Example
# Last 24 hours of TENANT_DELETED events
FROM_TS=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)
curl -G "https://api.zplflow/v1/audit/events" \
-H "Authorization: Bearer lb_live_xxxx" \
--data-urlencode "from=${FROM_TS}" \
--data-urlencode "action=TENANT_DELETED" \
--data-urlencode "limit=200"
import requests
from datetime import datetime, timedelta, timezone
headers = {"Authorization": "Bearer lb_live_xxxx"}
params = {
"from": (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat(),
"action": "TENANT_DELETED",
"limit": 200,
}
resp = requests.get("https://api.zplflow/v1/audit/events", headers=headers, params=params)
data = resp.json()
for event in data["events"]:
print(f"{event['timestamp']} {event['action']} by {event.get('actor_email')}")
print(f"Returned {data['meta']['count']} events")
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
func main() {
q := url.Values{}
q.Set("from", time.Now().Add(-24*time.Hour).UTC().Format(time.RFC3339))
q.Set("action", "TENANT_DELETED")
q.Set("limit", "200")
req, _ := http.NewRequest("GET",
"https://api.zplflow/v1/audit/events?"+q.Encode(), nil)
req.Header.Set("Authorization", "Bearer lb_live_xxxx")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var data struct {
Events []struct {
Timestamp string `json:"timestamp"`
Action string `json:"action"`
ActorEmail string `json:"actor_email"`
} `json:"events"`
Meta struct {
Count int `json:"count"`
} `json:"meta"`
}
json.NewDecoder(resp.Body).Decode(&data)
for _, e := range data.Events {
fmt.Printf("%s %s by %s\n", e.Timestamp, e.Action, e.ActorEmail)
}
fmt.Printf("Returned %d events\n", data.Meta.Count)
}
<?php
$from = gmdate('Y-m-d\TH:i:s\Z', time() - 86400);
$url = "https://api.zplflow/v1/audit/events?" . http_build_query([
"from" => $from,
"action" => "TENANT_DELETED",
"limit" => 200,
]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer lb_live_xxxx"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = json_decode(curl_exec($ch), true);
foreach ($data['events'] as $e) {
echo "{$e['timestamp']} {$e['action']} by {$e['actor_email']}\n";
}
echo "Returned {$data['meta']['count']} events\n";
AS400 / SQL Native (IBM i)
SELECT SYSTOOLS.HTTPGETCLOB(
'https://api.zplflow/v1/audit/events?from=2026-07-14T00:00:00Z&action=TENANT_DELETED&limit=200',
NULL,
'{"Authorization":"Bearer lb_live_xxxx"}'
) AS response
FROM SYSIBM.SYSDUMMY1;
AS400 / HTTPAPI (RPG)
dcl-s url varchar(500);
dcl-s response varchar(65000);
dcl-s rc int(10);
url = 'https://api.zplflow/v1/audit/events'
+ '?from=2026-07-14T00:00:00Z'
+ '&action=TENANT_DELETED'
+ '&limit=200';
rc = http_req('GET'
: %trim(url)
: *null
: %trim(response)
: 'Authorization: Bearer lb_live_xxxx'
: *null
: *null
: 30000
: *null);
Note:
meta.countis the number of events returned in the current page, not the total match count. Iterate by incrementingoffsetbylimituntil the returned page is empty.
POST /v1/tenants/sub-processor/notify
Sends a sub-processor change notification to all active tenants that have a breach contact configured with enabled: true. Each tenant receives an email containing the new sub-processor’s details and the deadline for raising an objection.
This endpoint is intended for administrative use by zplflow operations. Individual tenants do not call this endpoint — they receive the notification and object by writing to privacy@zplflow.io.
Headers
| Header | Value |
|---|---|
Authorization |
Bearer <api_key> |
Content-Type |
application/json |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Legal name of the new sub-processor |
service |
string | Yes | Type of service provided |
location |
string | No | Jurisdiction (country) where the sub-processor operates |
effective_date |
string | Yes | RFC3339 date when the sub-processor becomes effective |
Success Response
200 — Notifications queued for delivery.
{
"status": "sub_processor_notified",
"sub_processor": "Datadog, Inc.",
"effective_date": "2026-09-01T00:00:00Z",
"tenants_found": 42,
"sent": 41,
"failed": 1
}
| Field | Type | Description |
|---|---|---|
status |
string | Always sub_processor_notified |
sub_processor |
string | Name echoed from the request |
effective_date |
string | Effective date echoed from the request |
tenants_found |
integer | Number of tenants matching the notification criteria |
sent |
integer | Number of emails successfully sent |
failed |
integer | Number of emails that could not be delivered |
Examples
bash
curl -X POST https://api.example.com/v1/tenants/sub-processor/notify \
-H "Authorization: Bearer $ZPLFLOW_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Datadog, Inc.",
"service": "Observability and Monitoring",
"location": "United States",
"effective_date": "2026-09-01T00:00:00Z"
}'
python
import requests
import json
import os
resp = requests.post(
"https://api.example.com/v1/tenants/sub-processor/notify",
headers={
"Authorization": f"Bearer {os.environ['ZPLFLOW_API_KEY']}",
"Content-Type": "application/json",
},
json={
"name": "Datadog, Inc.",
"service": "Observability and Monitoring",
"location": "United States",
"effective_date": "2026-09-01T00:00:00Z",
},
)
print(json.dumps(resp.json(), indent=2))
go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
body := map[string]string{
"name": "Datadog, Inc.",
"service": "Observability and Monitoring",
"location": "United States",
"effective_date": "2026-09-01T00:00:00Z",
}
buf, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "https://api.example.com/v1/tenants/sub-processor/notify", bytes.NewReader(buf))
req.Header.Set("Authorization", "Bearer "+os.Getenv("ZPLFLOW_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
fmt.Println(result)
}
php
<?php
$apiKey = getenv('ZPLFLOW_API_KEY');
$body = json_encode([
'name' => 'Datadog, Inc.',
'service' => 'Observability and Monitoring',
'location' => 'United States',
'effective_date' => '2026-09-01T00:00:00Z',
]);
$ch = curl_init('https://api.example.com/v1/tenants/sub-processor/notify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $apiKey",
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
]);
$result = json_decode(curl_exec($ch), true);
echo json_encode($result, JSON_PRETTY_PRINT);
?>
AS400 / SQL (IBM i)
DECLARE :json VARCHAR(2000);
SET :json = '{
"name": "Datadog, Inc.",
"service": "Observability and Monitoring",
"location": "United States",
"effective_date": "2026-09-01T00:00:00Z"
}';
DECLARE :response CLOB;
CALL QSYS2.HTTP_POST_VERBOSE(
'https://api.example.com/v1/tenants/sub-processor/notify',
:json,
'<httpHeader>' ||
'<header name="Authorization" value="Bearer lb_live_xxxx"/>' ||
'<header name="Content-Type" value="application/json"/>' ||
'</httpHeader>',
:response
);
SELECT :response FROM SYSIBM.SYSDUMMY1;
AS400 / RPG (IBM i)
**free
dcl-s json varchar(2000);
dcl-s url varchar(2000);
dcl-s auth varchar(2000);
json = '{"name":"Datadog, Inc.","service":"Observability and Monitoring",' +
'"location":"United States","effective_date":"2026-09-01T00:00:00Z"}';
url = 'https://api.example.com/v1/tenants/sub-processor/notify';
auth = 'Bearer lb_live_xxxx';
exec sql
select response_message into :response
from table(
qsys2.http_post(
:url,
cast(:json as varchar(2000) ccsid 1208),
cast('<httpHeader>' ||
'<header name="Authorization" value="''' || trim(:auth) || '''"/>' ||
'<header name="Content-Type" value="application/json"/>' ||
'</httpHeader>' as varchar(2000))
)
);
*inlr = *on;
Note: This endpoint is an administrative operation that notifies all active tenants simultaneously. Run it only when you are ready to announce a new sub-processor. The effective date should be at least 30 days in the future to satisfy the SCC objection period.
Regulatory References
- Commission Implementing Decision (EU) 2021/915 of 4 June 2021 on standard contractual clauses for the transfer of personal data to third countries.
- SCC Clause 7.6 — Documenting and auditing processing activities.
- SCC Clause 7.7 — Sub-processor change notification and right to object.
- SCC Clause 9.3 — Notification of a personal data breach to the data controller.
Next Steps
- Authentication — Bearer tokens, error responses, required headers
- Best Practices — Idempotency, error handling, rate limits
- Code Examples — Multi-language snippets