Authentication
Every request acts on exactly one account, the one its credential belongs to. There are two kinds of credential: API keys, and SigV4 signatures from IAM principals in your own AWS account.
API keys
Section titled “API keys”Send the key in the Authorization header:
export BASE="https://api.rhinorelay.com/v1"export KEY="ek_..."
curl "$BASE/health" -H "Authorization: Bearer $KEY"Keys look like ek_<keyId>_<secret>. We store only a hash of the secret, so a key is shown once, when you create it. Use your Region’s base URL: https://api.rhinorelay.com/v1 if your account is in us-east-2, and https://api.<region>.rhinorelay.com/v1 for any other Region, or your own custom API domain. The console’s Setup page shows yours, and Regions and limits lists them all. A key sent to another Region’s host gets 421 wrong_region, because your data is only processed in your own Region.
Check it worked: /health answers 200. A missing, wrong or revoked key gives 401 unauthenticated.
Create a key for one job
Section titled “Create a key for one job”Give each system its own key with only the scopes it needs. For example, a signup form only adds contacts:
curl -X POST "$BASE/keys" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"name":"Signup form","scopes":["contacts:write"]}'The response has the secret: store it now, it’s never shown again. Use it:
curl -X POST "$BASE/contacts" -H "Authorization: Bearer <secret>" -H 'Content-Type: application/json' \ -d '{"email":"new.reader@example.com","consent":{"source":"signup-form","at":"2026-10-01T09:00:00Z","method":"single_opt_in"}}'
# Expect 403 forbiddencurl "$BASE/campaigns" -H "Authorization: Bearer <secret>"A key without the scope an endpoint needs gets 403 forbidden.
List and revoke keys
Section titled “List and revoke keys”curl "$BASE/keys" -H "Authorization: Bearer $KEY"curl -X DELETE "$BASE/keys/<key_...>" -H "Authorization: Bearer $KEY"
# Expect 401 unauthenticatedcurl "$BASE/health" -H "Authorization: Bearer <secret>"Check it worked: the revoked key shows a revokedAt time in the list, and stops working at once. You can also create and revoke keys on the console’s API keys page.
Scopes
Section titled “Scopes”admin includes every other scope, and contacts:write includes contacts:read.
| Scope | Allows | Endpoints that need it |
|---|---|---|
contacts:read | Read contacts, segments and imports. | POST /contacts/lookupGET /contactsGET /contacts/{id}GET /segmentsGET /segments/{id}GET /segments/{id}/countPOST /segments/countGET /importsGET /imports/{id} |
contacts:write | Everything in contacts:read, and change contacts, segments and imports. | POST /contactsPOST /contacts/batchPATCH /contacts/{id}POST /contacts/{id}/unsubscribePOST /contacts/{id}/resubscribeDELETE /contacts/{id}POST /segmentsPATCH /segments/{id}DELETE /segments/{id}POST /imports/csvPOST /imports/mailchimpPOST /imports/{id}/commitDELETE /imports/{id} |
campaigns:write | Create and change templates and draft campaigns, send tests, and read campaigns and reports. | POST /templatesPOST /templates/{id}/versionsGET /templatesGET /templates/{id}GET /templates/{id}/versionsPOST /templates/{id}/renderPOST /campaignsPATCH /campaigns/{id}POST /campaigns/{id}/testDELETE /campaigns/{id}GET /campaignsGET /campaigns/{id}GET /campaigns/{id}/reportGET /campaigns/{id}/recipients |
campaigns:send | Schedule, pause, resume and cancel campaigns. | POST /campaigns/{id}/schedulePOST /campaigns/{id}/pausePOST /campaigns/{id}/resumePOST /campaigns/{id}/cancel |
admin | Everything, including settings, domains, API keys and exports. | GET /exportsPOST /exportsGET /exports/{id}POST /setup/verifyPOST /setup/domainsGET /setup/domainsGET /settingsPATCH /settingsPOST /keysGET /keysDELETE /keys/{id} |
| Any valid key | Check setup and health. | GET /health |
SigV4 from your AWS account
Section titled “SigV4 from your AWS account”Your own IAM roles and users can call the API without a key, by signing requests with AWS Signature Version 4. Requests go to your Region’s SigV4 endpoint, https://<url-id>.lambda-url.<region>.on.aws/v1, shown on the console’s API keys page, and are signed for the service lambda. We accept a signature only from the AWS account that holds your data, the one you registered.
The caller needs permission to invoke our function URL. Attach a policy like this to the role:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "lambda:InvokeFunctionUrl", "lambda:InvokeFunction" ], "Resource": "arn:aws:lambda:<region>:<our account>:function:rhino-relay-prod-api:live-iam" } ]}SigV4 callers get the admin scope unless your settings map their role to fewer scopes:
curl -X PATCH "$BASE/settings" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"iamScopes":{"arn:aws:iam::123456789012:role/crm-sync":["contacts:write"]}}'With curl 7.75 or later, set SIGV4_BASE to your SigV4 endpoint and use credentials from your environment:
curl --aws-sigv4 "aws:amz:us-east-2:lambda" \ --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" -H "x-amz-security-token: $AWS_SESSION_TOKEN" \ "$SIGV4_BASE/health"In JavaScript, with the AWS SDK’s signer:
// npm install @smithy/signature-v4 @smithy/protocol-http @aws-crypto/sha256-js @aws-sdk/credential-provider-nodeimport { Sha256 } from "@aws-crypto/sha256-js";import { defaultProvider } from "@aws-sdk/credential-provider-node";import { HttpRequest } from "@smithy/protocol-http";import { SignatureV4 } from "@smithy/signature-v4";
const base = new URL(process.env.SIGV4_BASE); // https://<url-id>.lambda-url.us-east-2.on.aws/v1const signer = new SignatureV4({ service: "lambda", region: "us-east-2", credentials: defaultProvider(), sha256: Sha256,});
async function call(method, path, body) { const url = new URL(base.pathname + path, base); const request = new HttpRequest({ method, protocol: url.protocol, hostname: url.hostname, path: url.pathname, query: Object.fromEntries(url.searchParams), headers: { host: url.hostname, ...(body ? { "content-type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined, }); const signed = await signer.sign(request); const res = await fetch(url, { method, headers: signed.headers, body: signed.body }); return { status: res.status, body: await res.json() };}
console.log(await call("GET", "/health"));console.log( await call("POST", "/contacts", { email: "grace.hopper@example.com", consent: { source: "crm-sync", at: new Date().toISOString(), method: "single_opt_in" }, }),);In Python, with botocore:
# pip install botocoreimport jsonimport osimport urllib.request
import botocore.sessionfrom botocore.auth import SigV4Authfrom botocore.awsrequest import AWSRequest
BASE = os.environ["SIGV4_BASE"] # https://<url-id>.lambda-url.us-east-2.on.aws/v1credentials = botocore.session.get_session().get_credentials()
def call(method, path, body=None): data = json.dumps(body) if body is not None else None headers = {"Content-Type": "application/json"} if data else {} request = AWSRequest(method=method, url=BASE + path, data=data, headers=headers) SigV4Auth(credentials, "lambda", "us-east-2").add_auth(request) prepared = request.prepare() req = urllib.request.Request( prepared.url, data=data.encode() if data else None, headers=dict(prepared.headers), method=method, ) with urllib.request.urlopen(req) as res: return res.status, json.load(res)
print(call("GET", "/health"))print(call("POST", "/contacts", { "email": "grace.hopper@example.com", "consent": {"source": "crm-sync", "at": "2026-10-01T09:00:00Z", "method": "single_opt_in"},}))With a generated client library: the OpenAPI document declares only API keys as security, so generated libraries work with a key as they are. SigV4 is described in its x-sigv4 field. To use it, point the library at your SigV4 endpoint and sign each request in its request hook with the signer above.
Check it worked: /health answers 200. A request signed by another AWS account, or not signed for lambda in your Region, gives 401 unauthenticated or 403 forbidden.