Relays
Relays proxy outbound HTTP requests with secrets injected server-side. They let your end-users call external APIs (e.g., news APIs, payment providers, internal services) without exposing credentials to the client.
A relay is made up of two resources:
- Connectors — define the upstream service (base URL, auth type, headers, rate limits)
- Routes — define individual endpoint mappings (path, method, request transforms)
POST /relay/:routeId/invoke
{
"method": "GET",
"params": { "q": "tesla", "from": "2025-02-10" }
}Features
- Secrets decrypted server-side (supporting both base64 and
\xciphertext). - Allow-listed domains, header filters, SSRF guards.
- Route-level + user-level rate limits.
- Detailed response metadata: upstream status, headers, latency.
Managing Relays via the Management API
All relay management operations go through the Management API GraphQL endpoint (https://api.picklayer.ai/v1/graphql) using a builder organization token.
Connectors
A connector defines the upstream service that routes forward requests to.
List connectors
query ListConnectors($appId: uuid!) {
relay_connectors(
where: {
application_id: { _eq: $appId }
enabled: { _eq: true }
}
order_by: { created_at: desc }
) {
id name provider base_url auth_type rate_limit timeout_seconds created_at
}
}Create a connector
mutation CreateConnector($object: relay_connectors_insert_input!) {
insert_relay_connectors_one(object: $object) {
id name provider base_url created_at
}
}Variables:
{
"object": {
"application_id": "app-uuid",
"name": "News API",
"provider": "newsapi",
"base_url": "https://newsapi.org/v2",
"auth_type": "api_key",
"auth_config": {
"header": "X-Api-Key",
"secret_ref": "newsapi_key"
},
"default_headers": {
"Accept": "application/json"
},
"rate_limit": 100,
"timeout_seconds": 30
}
}| Field | Type | Required | Description |
|---|---|---|---|
application_id | uuid | yes | The application this connector belongs to |
name | string | yes | Display name |
provider | string | no | Provider identifier (informational) |
base_url | string | yes | Upstream base URL. All route paths are appended to this. |
auth_type | string | no | How to authenticate: api_key, bearer, basic, none |
auth_config | object | no | Auth details — e.g. { "header": "X-Api-Key", "secret_ref": "key_name" } |
default_headers | object | no | Headers added to every request through this connector |
rate_limit | integer | no | Max requests per minute (across all routes on this connector) |
timeout_seconds | integer | no | Request timeout in seconds (default: 30) |
Update a connector
mutation UpdateConnector($id: uuid!, $changes: relay_connectors_set_input!) {
update_relay_connectors_by_pk(
pk_columns: { id: $id }
_set: $changes
) {
id name updated_at
}
}Delete a connector (soft delete)
mutation DeleteConnector($id: uuid!) {
update_relay_connectors_by_pk(
pk_columns: { id: $id }
_set: { enabled: false }
) {
id enabled
}
}Manage connector secrets
Secrets are stored encrypted and injected at proxy time. Reference them in auth_config via secret_ref.
Create a secret:
mutation CreateConnectorSecret($object: relay_connector_secrets_insert_input!) {
insert_relay_connector_secrets_one(object: $object) {
id connector_id secret_name is_active created_at
}
}{
"object": {
"connector_id": "connector-uuid",
"secret_name": "newsapi_key",
"is_active": true
}
}Update a secret:
mutation UpdateConnectorSecret($id: uuid!, $changes: relay_connector_secrets_set_input!) {
update_relay_connector_secrets_by_pk(
pk_columns: { id: $id }
_set: $changes
) {
id secret_name is_active updated_at
}
}Connector secret encryption protocol
Connector secrets are stored and transmitted encrypted — the plaintext value never travels over the wire or rests in the database unprotected.
Overview of the flow:
- The builder configures
VITE_RELAY_SECRET_PUBLIC_KEY— an RSA public key in PEM/SPKI format. - The client encrypts the secret plaintext with
RSA-OAEP+SHA-256using the Web Crypto APIÂ . - The ciphertext is hex-encoded with a
\xprefix:\x+ hex(ciphertext bytes). This is theencrypted_valuefield. - The
encrypted_valueis sent when creating or updating a connector secret via GraphQL. - The server decrypts the value at proxy time using
RELAY_SECRET_PRIVATE_KEY(the matching RSA private key).
Constraints:
- Maximum plaintext length: 214 bytes (RSA-OAEP with a 2048-bit key).
- Only secrets encrypted with this scheme (prefixed
\x) are decrypted server-side. Unencrypted values stored as base64 are also accepted for legacy connectors.
Encrypting a secret (JavaScript / browser):
// Using the Web Crypto API (browser environment)
async function encryptSecret(plaintext, publicKeyPem) {
// Convert PEM to ArrayBuffer
const pemBody = publicKeyPem
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\s+/g, '');
const der = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0)).buffer;
const key = await crypto.subtle.importKey(
'spki',
der,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt'],
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
key,
new TextEncoder().encode(plaintext),
);
return '\\x' + Array.from(new Uint8Array(encrypted))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
// Usage
const encryptedValue = await encryptSecret('my-api-key-value', process.env.VITE_RELAY_SECRET_PUBLIC_KEY);
// encryptedValue looks like: \x3a9f... (256 hex chars for a 2048-bit key)Pass the returned string as encrypted_value when mutating relay_connector_secrets.
Routes
A route maps an invocable path to an upstream connector endpoint, with optional request/response transformation.
List routes
query ListRoutes($appId: uuid!) {
relay_routes(
where: {
application_id: { _eq: $appId }
deleted: { _neq: true }
}
order_by: { created_at: desc }
) {
id connector_id name path method enabled qps_limit cache_ttl_seconds created_at
connector { name base_url }
}
}List active routes for a connector
query ActiveRoutesByConnector($connectorId: uuid!) {
relay_routes(
where: {
connector_id: { _eq: $connectorId }
enabled: { _eq: true }
}
) {
id name path method qps_limit
}
}Create a route
mutation CreateRoute($object: relay_routes_insert_input!) {
insert_relay_routes_one(object: $object) {
id name path method created_at
}
}Variables:
{
"object": {
"connector_id": "connector-uuid",
"application_id": "app-uuid",
"name": "Top headlines",
"path": "/everything",
"method": "GET",
"request_template": {
"params": {
"q": "{{query}}",
"pageSize": "{{limit}}"
}
},
"qps_limit": 10,
"cache_ttl_seconds": 300
}
}Omit cache_ttl_seconds or set it to null to leave route response caching disabled. Set a positive integer TTL only when the route should explicitly cache responses.
| Field | Type | Required | Description |
|---|---|---|---|
connector_id | uuid | yes | The connector to forward requests to |
application_id | uuid | yes | Application this route belongs to |
name | string | yes | Display name |
path | string | yes | Path appended to the connector’s base_url |
method | string | yes | HTTP method: GET, POST, PUT, PATCH, DELETE |
request_template | object | no | Template for mapping invoke params to the upstream request |
body_template | object | no | Template for mapping invoke body to the upstream request body |
response_transform | object | no | Transform applied to the upstream response before returning to the client |
qps_limit | integer | no | Queries per second limit for this route |
cache_ttl_seconds | integer | no | Positive TTL for cached route responses; omitted or null disables route caching |
Update a route
mutation UpdateRoute($id: uuid!, $changes: relay_routes_set_input!) {
update_relay_routes_by_pk(
pk_columns: { id: $id }
_set: $changes
) {
id name enabled updated_at
}
}Delete a route (soft delete)
mutation DeleteRoute($id: uuid!) {
update_relay_routes_by_pk(
pk_columns: { id: $id }
_set: { deleted: true }
) {
id deleted
}
}Invoking a Relay Route
End-users invoke relay routes via the REST endpoint using their end-user JWT:
curl -X POST "https://api.picklayer.dev/api/org/{org}/app/{app}/relay/{routeId}/invoke" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"method": "GET",
"params": { "q": "tesla", "from": "2025-02-10" }
}'The proxy injects secrets, applies any request template, calls the upstream, and returns the upstream response with metadata.
Python Example
import requests
HASURA_URL = "https://api.picklayer.ai/v1/graphql"
def get_headers(token):
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def create_connector(token, app_id, name, base_url, auth_type, auth_config=None):
resp = requests.post(HASURA_URL, json={
"query": """
mutation($object: relay_connectors_insert_input!) {
insert_relay_connectors_one(object: $object) {
id name base_url created_at
}
}
""",
"variables": {
"object": {
"application_id": app_id,
"name": name,
"base_url": base_url,
"auth_type": auth_type,
"auth_config": auth_config or {},
}
},
}, headers=get_headers(token))
return resp.json()["data"]["insert_relay_connectors_one"]
def create_route(token, connector_id, app_id, name, path, method="GET"):
resp = requests.post(HASURA_URL, json={
"query": """
mutation($object: relay_routes_insert_input!) {
insert_relay_routes_one(object: $object) {
id name path method created_at
}
}
""",
"variables": {
"object": {
"connector_id": connector_id,
"application_id": app_id,
"name": name,
"path": path,
"method": method,
}
},
}, headers=get_headers(token))
return resp.json()["data"]["insert_relay_routes_one"]
def list_routes(token, app_id):
resp = requests.post(HASURA_URL, json={
"query": """
query($appId: uuid!) {
relay_routes(where: {
application_id: {_eq: $appId},
deleted: {_neq: true}
}, order_by: {created_at: desc}) {
id name path method enabled
connector { name base_url }
}
}
""",
"variables": {"appId": app_id},
}, headers=get_headers(token))
return resp.json()["data"]["relay_routes"]