Subscriber preferences
Honouring an opt-out is a legal requirement under the Nigeria Data Protection Act (NDPA 2023) and GDPR. In Nervly, subscriber preferences are stored in PostgreSQL and evaluated by delivery workers immediately before dispatch, ensuring suppressed channels are never contacted and never billed. The controller–processor terms and the rest of the privacy posture are set out in the privacy policy.
Workspace-scoped preferences
Subscriber preferences are strictly isolated per tenant workspace (UNIQUE(workspace_id, subscriber_id)). Preferences configured in one workspace never collide with another workspace.
Updating channel and category preferences
PUT /v1/users/{subscriberId}/preferences is how you honour opt-outs. The write is a merge, not a replace, one level deep: a channel you omit keeps its current value, and a category you send replaces that category's flags wholesale. To re-enable a channel, send it explicitly as true. The merge happens inside PostgreSQL rather than as a read-modify-write, so two concurrent updates to different channels cannot lose each other.
The request body is a UserPreferencesRequest:
channels— per-channel booleans. All five delivery channels are accepted:email,push,sms,voice, andwhatsapp. Omitting a channel leaves it untouched; setting it tofalsesuppresses every notification on that channel for the subscriber, including ones a workflow marksCRITICAL.categories— your own category name mapped to a channel-boolean object, for example{"marketing": {"email": false, "sms": false}}. Category rules take precedence over the channel flags.
Supplying neither channels nor categories is a 400 BAD_REQUEST; there is nothing to write.
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
curl --fail-with-body -sS -X PUT https://api.nervly.io/v1/users/user_8f21c/preferences \
--connect-timeout 5 \
--max-time 10 \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channels": {
"email": true,
"push": false,
"sms": true,
"voice": false,
"whatsapp": false
},
"categories": {
"marketing": { "email": false, "sms": false },
"security": { "email": true, "sms": true }
}
}'
import { Nervly, NervlyValidationError, type UserPreferencesResponse } from '@nervly/sdk';
const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY });
try {
const result: UserPreferencesResponse = await nervly.users.updatePreferences('user_8f21c', {
channels: {
email: true,
push: false,
sms: true,
voice: false,
whatsapp: false,
},
categories: {
marketing: { email: false, sms: false },
security: { email: true, sms: true },
},
});
console.log(`Preferences ${result.status} for ${result.subscriberId} at ${result.updated_at}`);
} catch (error) {
if (error instanceof NervlyValidationError) {
console.error(`Rejected (${error.errorType}): ${error.message}`);
process.exitCode = 1;
} else {
throw error;
}
}
nervly.subscribers.updatePreferences(subscriberId, data) is the older alias of the
same call and behaves identically.
import os
import requests
response = requests.put(
"https://api.nervly.io/v1/users/user_8f21c/preferences",
headers={"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}"},
json={
"channels": {
"email": True,
"push": False,
"sms": True,
"voice": False,
"whatsapp": False,
},
"categories": {
"marketing": {"email": False, "sms": False},
"security": {"email": True, "sms": True},
},
},
timeout=10,
)
if response.status_code == 400:
error = response.json()
raise SystemExit(f"Rejected ({error['error']}): {error['message']}")
response.raise_for_status()
preferences = response.json()
print(
f"Preferences {preferences['status']} for "
f"{preferences['subscriberId']} at {preferences['updated_at']}"
)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type preferencesResponse struct {
Status string `json:"status"`
SubscriberID string `json:"subscriberId"`
UpdatedAt string `json:"updated_at"`
}
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
StatusCode int `json:"status_code"`
}
func main() {
payload := map[string]any{
"channels": map[string]bool{
"email": true,
"push": false,
"sms": true,
"voice": false,
"whatsapp": false,
},
"categories": map[string]map[string]bool{
"marketing": {"email": false, "sms": false},
"security": {"email": true, "sms": true},
},
}
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
req, err := http.NewRequest("PUT", "https://api.nervly.io/v1/users/user_8f21c/preferences", bytes.NewBuffer(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
var apiErr errorResponse
_ = json.Unmarshal(raw, &apiErr)
fmt.Fprintf(os.Stderr, "Rejected (%s): %s\n", apiErr.Error, apiErr.Message)
os.Exit(1)
}
var result preferencesResponse
if err := json.Unmarshal(raw, &result); err != nil {
panic(err)
}
fmt.Printf("Preferences %s for %s at %s\n", result.Status, result.SubscriberID, result.UpdatedAt)
}
<?php
$payload = [
'channels' => [
'email' => true,
'push' => false,
'sms' => true,
'voice' => false,
'whatsapp' => false,
],
'categories' => [
'marketing' => ['email' => false, 'sms' => false],
'security' => ['email' => true, 'sms' => true],
],
];
$ch = curl_init('https://api.nervly.io/v1/users/user_8f21c/preferences');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NERVLY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$result = json_decode($response, true);
if ($status >= 400) {
fwrite(STDERR, "Rejected ({$result['error']}): {$result['message']}\n");
exit(1);
}
echo "Preferences {$result['status']} for {$result['subscriberId']} at {$result['updated_at']}\n";
use reqwest::Client;
use serde::Deserialize;
use serde_json::json;
use std::env;
use std::error::Error;
use std::time::Duration;
#[derive(Debug, Deserialize)]
struct PreferencesResponse {
status: String,
#[serde(rename = "subscriberId")]
subscriber_id: String,
updated_at: String,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: String,
message: String,
status_code: u16,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let payload = json!({
"channels": {
"email": true,
"push": false,
"sms": true,
"voice": false,
"whatsapp": false
},
"categories": {
"marketing": { "email": false, "sms": false },
"security": { "email": true, "sms": true }
}
});
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.put("https://api.nervly.io/v1/users/user_8f21c/preferences")
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
let status = response.status();
let raw = response.text().await?;
if !status.is_success() {
let api_error: ErrorResponse = serde_json::from_str(&raw)?;
return Err(format!("Rejected ({}): {}", api_error.error, api_error.message).into());
}
let result: PreferencesResponse = serde_json::from_str(&raw)?;
println!(
"Preferences {} for {} at {}",
result.status, result.subscriber_id, result.updated_at
);
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class UpdatePreferences {
public static void main(String[] args) throws Exception {
String payload = """
{
"channels": {
"email": true,
"push": false,
"sms": true,
"voice": false,
"whatsapp": false
},
"categories": {
"marketing": { "email": false, "sms": false },
"security": { "email": true, "sms": true }
}
}
""";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/users/user_8f21c/preferences"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new IllegalStateException("Rejected " + response.statusCode() + ": " + response.body());
}
System.out.println("Stored: " + response.body());
}
}
The JDK ships no JSON parser, so this example surfaces the status and body and
leaves field-level deserialization to your JSON library of choice (Jackson,
Gson, or jakarta.json).
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
public class SubscriberPreferences
{
[JsonPropertyName("subscriberId")]
public string SubscriberId { get; set; } = "";
public string Status { get; set; } = "";
[JsonPropertyName("updated_at")]
public string UpdatedAt { get; set; } = "";
}
public class ApiError
{
public string Error { get; set; } = "";
public string Message { get; set; } = "";
[JsonPropertyName("status_code")]
public int StatusCode { get; set; }
}
public class Program
{
public static async Task Main()
{
var payload = new
{
channels = new
{
email = true,
push = false,
sms = true,
voice = false,
whatsapp = false,
},
categories = new
{
marketing = new { email = false, sms = false },
security = new { email = true, sms = true },
},
};
var body = JsonSerializer.Serialize(payload);
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = new HttpRequestMessage(HttpMethod.Put, "https://api.nervly.io/v1/users/user_8f21c/preferences");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
var apiError = JsonSerializer.Deserialize<ApiError>(responseBody)!;
throw new HttpRequestException($"Rejected ({apiError.Error}): {apiError.Message}");
}
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var result = JsonSerializer.Deserialize<SubscriberPreferences>(responseBody, options)!;
Console.WriteLine($"Preferences {result.Status} for {result.SubscriberId} at {result.UpdatedAt}");
}
}
require 'json'
require 'net/http'
require 'uri'
uri = URI('https://api.nervly.io/v1/users/user_8f21c/preferences')
request = Net::HTTP::Put.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
request['Content-Type'] = 'application/json'
request.body = {
channels: {
email: true,
push: false,
sms: true,
voice: false,
whatsapp: false
},
categories: {
marketing: { email: false, sms: false },
security: { email: true, sms: true }
}
}.to_json
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 10
response = http.request(request)
result = JSON.parse(response.body)
unless response.is_a?(Net::HTTPSuccess)
raise "Rejected (#{result['error']}): #{result['message']}"
end
puts "Preferences #{result['status']} for #{result['subscriberId']} at #{result['updated_at']}"
import Foundation
struct ChannelPreferences: Encodable {
let email: Bool
let push: Bool
let sms: Bool
let voice: Bool
let whatsapp: Bool
}
struct PreferencesRequest: Encodable {
let channels: ChannelPreferences
let categories: [String: [String: Bool]]
}
struct PreferencesResponse: Decodable {
let status: String
let subscriberId: String
let updated_at: String
}
struct ApiError: Decodable {
let error: String
let message: String
let status_code: Int
}
let payload = PreferencesRequest(
channels: ChannelPreferences(email: true, push: false, sms: true, voice: false, whatsapp: false),
categories: [
"marketing": ["email": false, "sms": false],
"security": ["email": true, "sms": true]
]
)
let body = try JSONEncoder().encode(payload)
var request = URLRequest(url: URL(string: "https://api.nervly.io/v1/users/user_8f21c/preferences")!)
request.httpMethod = "PUT"
request.timeoutInterval = 10
request.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = body
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("Request failed: \(error)")
return
}
guard let http = response as? HTTPURLResponse, let data = data else { return }
let decoder = JSONDecoder()
if !(200..<300).contains(http.statusCode) {
let apiError = try? decoder.decode(ApiError.self, from: data)
print("Rejected (\(apiError?.error ?? "unknown")): \(apiError?.message ?? "")")
return
}
let result = try? decoder.decode(PreferencesResponse.self, from: data)
print("Preferences \(result?.status ?? "") for \(result?.subscriberId ?? "") at \(result?.updated_at ?? "")")
}.resume()
semaphore.wait()
On success the gateway returns 200 with a UserPreferencesResponse:
{
"status": "UPDATED",
"subscriberId": "user_8f21c",
"updated_at": "2026-09-11T20:15:00.123Z"
}
Reading preferences back
There is no public GET for preferences on the gateway yet. The PUT
response above is the confirmation that the stored state changed; the gateway
does not expose a read endpoint to fetch it. The subject-access export described
under Subject access export is the supported way to
retrieve a subscriber's full record and preference history, and it runs on the
console host rather than the API gateway. Do not build against
GET /v1/users/{subscriberId}/preferences: that path accepts only PUT, so a
GET is not part of the contract and is rejected.
Errors
Every non-2xx response uses the same flat three-field ErrorResponse:
{
"error": "BAD_REQUEST",
"message": "Supply at least one of channels or categories",
"status_code": 400
}
PUT /v1/users/{subscriberId}/preferences can return:
| Status | error | Cause | Retry? |
|---|---|---|---|
400 | BAD_REQUEST | Neither channels nor categories was supplied | No — fix the request |
401 | UNAUTHORIZED | Missing or invalid API key | No — fix the key |
500 | INTERNAL_SERVER_ERROR | The preference store is unavailable and nothing was written | Yes |
Each example above branches on the HTTP status and extracts error and
message from the body. Branch on error, never on message. See
Errors for the full envelope and retry policy.
Subscriber erasure (NDPR Right to Erasure)
DELETE /v1/subscribers/{subscriberId} erases a subscriber to satisfy NDPA 2023
data-subject erasure mandates. It is idempotent: erasing a subscriber that
does not exist (or erasing the same subscriber twice) still returns 202, so
there is no 404 to handle. The response is a
SubscriberErasureResponse:
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
curl --fail-with-body -sS -X DELETE https://api.nervly.io/v1/subscribers/user_8f21c \
--connect-timeout 5 \
--max-time 10 \
-H "Authorization: Bearer $NERVLY_API_KEY"
import { Nervly, NervlyAuthenticationError } from '@nervly/sdk';
const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY });
try {
const result = await nervly.subscribers.delete('user_8f21c');
console.log(`Erasure ${result.status}: ${result.message}`);
} catch (error) {
if (error instanceof NervlyAuthenticationError) {
console.error(`Authentication failed: ${error.message}`);
process.exitCode = 1;
} else {
throw error;
}
}
import os
import requests
response = requests.delete(
"https://api.nervly.io/v1/subscribers/user_8f21c",
headers={"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}"},
timeout=10,
)
if response.status_code == 401:
error = response.json()
raise SystemExit(f"Authentication failed ({error['error']}): {error['message']}")
response.raise_for_status()
result = response.json()
print(f"Erasure {result['status']}: {result['message']}")
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type erasureResponse struct {
Status string `json:"status"`
SubscriberID string `json:"subscriberId"`
Message string `json:"message"`
}
func main() {
req, err := http.NewRequest("DELETE", "https://api.nervly.io/v1/subscribers/user_8f21c", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
fmt.Fprintf(os.Stderr, "Erasure failed with status %d\n", resp.StatusCode)
os.Exit(1)
}
var result erasureResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Printf("Erasure %s: %s\n", result.Status, result.Message)
}
<?php
$ch = curl_init('https://api.nervly.io/v1/subscribers/user_8f21c');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NERVLY_API_KEY'),
],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$result = json_decode($response, true);
if ($status >= 400) {
fwrite(STDERR, "Erasure failed ({$result['error']}): {$result['message']}\n");
exit(1);
}
echo "Erasure {$result['status']}: {$result['message']}\n";
use reqwest::Client;
use serde::Deserialize;
use std::env;
use std::error::Error;
use std::time::Duration;
#[derive(Debug, Deserialize)]
struct ErasureResponse {
status: String,
#[serde(rename = "subscriberId")]
subscriber_id: String,
message: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.delete("https://api.nervly.io/v1/subscribers/user_8f21c")
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.send()
.await?;
let status = response.status();
let raw = response.text().await?;
if !status.is_success() {
return Err(format!("Erasure failed with status {status}: {raw}").into());
}
let result: ErasureResponse = serde_json::from_str(&raw)?;
println!("Erasure {}: {}", result.status, result.message);
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class EraseSubscriber {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/subscribers/user_8f21c"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.DELETE()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new IllegalStateException("Erasure failed " + response.statusCode() + ": " + response.body());
}
System.out.println("Erased: " + response.body());
}
}
using System;
using System.Net.Http;
using System.Threading.Tasks;
public class EraseSubscriber
{
public static async Task Main()
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = new HttpRequestMessage(HttpMethod.Delete, "https://api.nervly.io/v1/subscribers/user_8f21c");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
using var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Erasure failed {(int)response.StatusCode}: {responseBody}");
}
Console.WriteLine($"Erased: {responseBody}");
}
}
require 'json'
require 'net/http'
require 'uri'
uri = URI('https://api.nervly.io/v1/subscribers/user_8f21c')
request = Net::HTTP::Delete.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 10
response = http.request(request)
result = JSON.parse(response.body)
unless response.is_a?(Net::HTTPSuccess)
raise "Erasure failed (#{result['error']}): #{result['message']}"
end
puts "Erasure #{result['status']}: #{result['message']}"
import Foundation
struct ErasureResponse: Decodable {
let status: String
let subscriberId: String
let message: String
}
var request = URLRequest(url: URL(string: "https://api.nervly.io/v1/subscribers/user_8f21c")!)
request.httpMethod = "DELETE"
request.timeoutInterval = 10
request.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("Request failed: \(error)")
return
}
guard let http = response as? HTTPURLResponse, let data = data else { return }
if !(200..<300).contains(http.statusCode) {
print("Erasure failed \(http.statusCode): \(String(data: data, encoding: .utf8) ?? "")")
return
}
let result = try? JSONDecoder().decode(ErasureResponse.self, from: data)
print("Erasure \(result?.status ?? ""): \(result?.message ?? "")")
}.resume()
semaphore.wait()
The 202 Accepted response confirms the erasure:
{
"status": "accepted",
"subscriberId": "user_8f21c",
"message": "Subscriber erasure initiated and completed"
}
Erasure can return 401 UNAUTHORIZED for a missing or invalid key and 500 INTERNAL_SERVER_ERROR when the preference store is unavailable and the erasure
failed. It does not return 404; see Errors for the
flat error envelope.
What happens on erasure:
- The subscriber record is anonymised: email, phone, and push tokens are cleared and the profile is marked deleted.
- Stored channel preferences for the subscriber are removed.
- Contact fields on messages older than 30 days are nulled.
- If a message is currently enqueued for the erased subscriber, the worker detects the deletion and suppresses delivery with status
SUPPRESSED. - An immutable entry is written to the tamper-evident audit trail.
Nigerian NCC 2442 DND compliance
For SMS traffic terminating in Nigeria (+234), Nervly integrates mandatory Do-Not-Disturb (DND 2442) compliance directly into the Go worker router:
- MSISDN Normalization: Automatically standardizes Nigerian phone numbers (
+234...,080...,090...,070...,081...) to international E.164 digits without+for provider lookup. - DND Cache: DND lookups are cached for 1 hour, keyed by a non-reversible SHA-256 digest of the number — no plaintext phone number is ever stored in the cache.
- Asymmetric Failure Posture:
- Marketing Traffic: Fails closed. If DND status check fails or indicates DND is active, the SMS is suppressed (
SUPPRESSED). - Transactional / OTP Traffic: Fails open. Critical security and transactional OTPs route via high-priority transactional routes (corporate CLI) rather than failing.
- Marketing Traffic: Fails closed. If DND status check fails or indicates DND is active, the SMS is suppressed (
- Audit Evidence: Every DND decision is recorded to an immutable, tamper-evident audit trail with a SHA-256 hashed MSISDN, intent, category, and outcome — preserving verifiable regulatory evidence without storing raw recipient numbers.
Subject access export
To satisfy NDPA 2023 data access requests, administrators can export a subscriber's complete history as JSON from the console:
curl https://console.nervly.io/console/subscribers/user_8f21c/export \
-H "Cookie: nervly_session=$SESSION_COOKIE"
This is the retrieval path for a subscriber's stored preferences and message
history: the public gateway exposes no GET for preferences. The export runs on
the console host and is authenticated with an administrator session rather than
an API key.
Next
- Errors — the flat error envelope and what is safe to retry
- Reading message status
- Channels and providers
- Quotas and billing