Skip to content

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.

Send the key in the Authorization header:

Terminal window
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.

Give each system its own key with only the scopes it needs. For example, a signup form only adds contacts:

Terminal window
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:

Terminal window
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 forbidden
curl "$BASE/campaigns" -H "Authorization: Bearer <secret>"

A key without the scope an endpoint needs gets 403 forbidden.

Terminal window
curl "$BASE/keys" -H "Authorization: Bearer $KEY"
curl -X DELETE "$BASE/keys/<key_...>" -H "Authorization: Bearer $KEY"
# Expect 401 unauthenticated
curl "$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.

admin includes every other scope, and contacts:write includes contacts:read.

ScopeAllowsEndpoints that need it
contacts:readRead contacts, segments and imports.POST /contacts/lookup
GET /contacts
GET /contacts/{id}
GET /segments
GET /segments/{id}
GET /segments/{id}/count
POST /segments/count
GET /imports
GET /imports/{id}
contacts:writeEverything in contacts:read, and change contacts, segments and imports.POST /contacts
POST /contacts/batch
PATCH /contacts/{id}
POST /contacts/{id}/unsubscribe
POST /contacts/{id}/resubscribe
DELETE /contacts/{id}
POST /segments
PATCH /segments/{id}
DELETE /segments/{id}
POST /imports/csv
POST /imports/mailchimp
POST /imports/{id}/commit
DELETE /imports/{id}
campaigns:writeCreate and change templates and draft campaigns, send tests, and read campaigns and reports.POST /templates
POST /templates/{id}/versions
GET /templates
GET /templates/{id}
GET /templates/{id}/versions
POST /templates/{id}/render
POST /campaigns
PATCH /campaigns/{id}
POST /campaigns/{id}/test
DELETE /campaigns/{id}
GET /campaigns
GET /campaigns/{id}
GET /campaigns/{id}/report
GET /campaigns/{id}/recipients
campaigns:sendSchedule, pause, resume and cancel campaigns.POST /campaigns/{id}/schedule
POST /campaigns/{id}/pause
POST /campaigns/{id}/resume
POST /campaigns/{id}/cancel
adminEverything, including settings, domains, API keys and exports.GET /exports
POST /exports
GET /exports/{id}
POST /setup/verify
POST /setup/domains
GET /setup/domains
GET /settings
PATCH /settings
POST /keys
GET /keys
DELETE /keys/{id}
Any valid keyCheck setup and health.GET /health

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:

Terminal window
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:

Terminal window
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-node
import { 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/v1
const 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 botocore
import json
import os
import urllib.request
import botocore.session
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
BASE = os.environ["SIGV4_BASE"] # https://<url-id>.lambda-url.us-east-2.on.aws/v1
credentials = 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.