zplflow logo

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: false to 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.count is the number of events returned in the current page, not the total match count. Iterate by incrementing offset by limit until 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