Skip to Content
Relays

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 \x ciphertext).
  • 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 } }
FieldTypeRequiredDescription
application_iduuidyesThe application this connector belongs to
namestringyesDisplay name
providerstringnoProvider identifier (informational)
base_urlstringyesUpstream base URL. All route paths are appended to this.
auth_typestringnoHow to authenticate: api_key, bearer, basic, none
auth_configobjectnoAuth details — e.g. { "header": "X-Api-Key", "secret_ref": "key_name" }
default_headersobjectnoHeaders added to every request through this connector
rate_limitintegernoMax requests per minute (across all routes on this connector)
timeout_secondsintegernoRequest 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:

  1. The builder configures VITE_RELAY_SECRET_PUBLIC_KEY — an RSA public key in PEM/SPKI format.
  2. The client encrypts the secret plaintext with RSA-OAEP + SHA-256 using the Web Crypto API .
  3. The ciphertext is hex-encoded with a \x prefix: \x + hex(ciphertext bytes). This is the encrypted_value field.
  4. The encrypted_value is sent when creating or updating a connector secret via GraphQL.
  5. 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.

FieldTypeRequiredDescription
connector_iduuidyesThe connector to forward requests to
application_iduuidyesApplication this route belongs to
namestringyesDisplay name
pathstringyesPath appended to the connector’s base_url
methodstringyesHTTP method: GET, POST, PUT, PATCH, DELETE
request_templateobjectnoTemplate for mapping invoke params to the upstream request
body_templateobjectnoTemplate for mapping invoke body to the upstream request body
response_transformobjectnoTransform applied to the upstream response before returning to the client
qps_limitintegernoQueries per second limit for this route
cache_ttl_secondsintegernoPositive 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"]
Last updated on