Actions

Actions let your static app call any HTTPS API without leaking credentials. Wire a Submit button to a Slack webhook, a form to HubSpot, a dashboard to BigQuery — with secrets encrypted at rest, audit logs, rate limits, and OAuth token management all handled for you.

Paid feature
Actions require Pro or higher. See pricing.

What Actions are

A Deloc Action is a pre-configured HTTP request. You define what the request looks like — method, URL, headers, body — and mark which bits come from the browser and which come from encrypted server-side storage. When your app calls the action, Deloc's edge fills in the template, forwards the request, and returns the upstream response to your app.

The viewer's browser never sees the target URL, the API key, the OAuth token, or anything else sensitive. That means you can add real integrations to a static site without standing up a backend.

Anatomy of an Action

Action fields
namestringrequiredSlug-style identifier your app uses to invoke it.
methodGET | POST | PUT | PATCH | DELETErequiredHTTP method.
target_urlstringrequiredFull https:// URL. Supports all three template kinds.
header_templateRecord<string,string>optionalOutbound headers. Templated.
body_templatestringoptionalRequest body (usually JSON). Templated.
allowed_variablesstring[]optionalLowercase variable names the browser may pass. Anything else is rejected.
allowed_roles(publisher|admin|viewer)[]optionalWho may invoke. Defaults to publisher+admin.
external_id_variablestringoptionalVariable whose value is stamped onto the log row for dedup or audit.
rate_limit_per_viewer_per_hournumberoptionalDefault 60. Max 3600.
rate_limit_per_app_per_hournumberoptionalDefault 1000. Max 100000.
timeout_msnumberoptionalUpstream request timeout. Default 30000.
max_response_bytesnumberoptionalMax upstream response size. Default 1MB.
credential_namestringoptionalAttach an OAuth credential. Injects ${OAUTH_ACCESS_TOKEN}.

Creating an action — end to end

Let's wire a static dashboard so a button POSTs a message to a Slack webhook.

Step 1 — Create the action with a secret placeholder

shell
deloc actions create my-dashboard send-slack \
  --display-name "Send Slack alert" \
  --method POST \
  --target-url "${SLACK_WEBHOOK_URL}" \
  --header "Content-Type=application/json" \
  --body '{"text":"{message} — from {{viewer.email}}"}' \
  --allowed-variables message \
  --external-id-variable message \
  --rate-viewer 10

The ${SLACK_WEBHOOK_URL} placeholder in --target-url is a secret, not a runtime variable. It will be filled in server-side from your encrypted secret store. {message} is a runtime variable the browser passes. {{viewer.email}} is trusted context Deloc sets from the viewer's session.

Step 2 — Set the secret

shell
deloc actions secret set my-dashboard send-slack SLACK_WEBHOOK_URL \
  --value "https://hooks.slack.com/services/T.../B.../..."

Omit --value to be prompted with hidden input. The value is encrypted with libsodium secretbox and stored. It is never returned by any API after this point.

Step 3 — Test before shipping

shell
deloc actions test my-dashboard send-slack --body '{"message":"hello from the CLI"}'

Test invocations are logged with errorType='test' so they don't count toward auto-disable. Check the Slack channel — you should see the message.

Step 4 — Call it from your app

Install the client SDK in your published app's project (same package.json you deploy from):

shell
npm install @deloc/client
tsx
import { useDelocAction } from "@deloc/client/react";

export function SubmitButton() {
  const { invoke, loading, error } = useDelocAction("send-slack");

  async function handleClick() {
    const result = await invoke({ message: "Click!" });
    if (result.success) {
      console.log("sent");
    }
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? "Sending…" : "Notify Slack"}
    </button>
  );
}
Same-origin, no CORS
The SDK calls the action on the app's own subdomain ({slug}.deloc.dev). The Worker uses the viewer's session cookie to authenticate, so you don't need to deal with CORS or bearer tokens in browser code.

Template syntax

Every templated field (URL, headers, body) supports three kinds of placeholder.

Runtime variables — {lowercase}

Values the browser passes on invocation. Lowercase only. URL-encoded at runtime when used in a URL. Must appear in --allowed-variables.

shell
--target-url "https://api.example.com/orders/{order_id}"
--allowed-variables "order_id"

# In your app:
# await deloc.action("refund", { order_id: "42" })

Secrets — ${UPPERCASE}

Names must be all uppercase. The value comes from encrypted secret storage and is injected server-side. Viewers never see it.

shell
--header "Authorization=Bearer ${API_KEY}"
# and separately:
# deloc actions secret set my-app my-action API_KEY --value "sk_live_..."

Trusted context — {{viewer.email}}

Values only Deloc can set. Not controllable by the browser. Supports:

  • {{viewer.email}} — the signed-in viewer's email
  • {{viewer.id}} — viewer's internal ID
  • {{now}} — ISO-8601 timestamp at invocation
  • {{action.name}} — the action name (useful in logs)
Gotcha: lowercase after $ is not a secret
${api_key} (lowercase) is treated as a literal dollar-brace sequence, not secret syntax. Secrets must use ${UPPERCASE_NAMES}. This is an intentional guard so a typo can't accidentally pull in a secret you didn't mean to reference.

Secrets

Secrets are per-action. Values never leave the server after they're written.

shell
# Set or rotate
deloc actions secret set my-dashboard send-slack SLACK_WEBHOOK_URL \
  --value "https://hooks.slack.com/services/..."

# Or be prompted with hidden input
deloc actions secret set my-dashboard send-slack API_KEY

# List names (values are write-only)
deloc actions secret list my-dashboard send-slack

# Delete
deloc actions secret delete my-dashboard send-slack OLD_KEY

The platform uses libsodium secretbox (XSalsa20-Poly1305) with a versioned master key. Key rotation is supported and happens transparently — a keyVersion column on each secret row tracks which master key wrapped it. When the master key rotates, old rows are re-encrypted on the next update.

OAuth credentials

Most real APIs need short-lived access tokens, not static API keys. Instead of storing a client secret as a plain action secret and writing token-exchange code yourself, configure an OAuth credential once and let Deloc handle the token mint + cache.

Create a credential

The default grant for machine-to-machine OAuth. Used by Salesforce, Auth0, Okta M2M, most B2B APIs.

shell
deloc credentials create salesforce-prod \
  --display-name "Salesforce prod" \
  --grant client_credentials \
  --token-url "https://login.salesforce.com/services/oauth2/token" \
  --client-id "3MVG9..." \
  --client-secret "XYZ..." \
  --scopes "api refresh_token"

Attach to an action

shell
deloc actions create my-dashboard refresh-sales \
  --display-name "Refresh sales data" \
  --method POST \
  --target-url "https://www.googleapis.com/bigquery/v2/projects/my-project/queries" \
  --header "Authorization=Bearer ${OAUTH_ACCESS_TOKEN}" \
  --header "Content-Type=application/json" \
  --body '{"query":"SELECT * FROM sales","useLegacySql":false}' \
  --credential gcp-bigquery

Deloc exchanges the credential for a fresh token (cached until expiry) and substitutes ${OAUTH_ACCESS_TOKEN} on each invocation. Rotate the credential with deloc credentials update.


Invoking from your app

The @deloc/client SDK is a ~3KB shim over fetch. It calls the action on your app's own subdomain (same-origin, no CORS) and authenticates via the viewer's session cookie.

shell
npm install @deloc/client
SubmitButton.tsx
import { useDelocAction } from "@deloc/client/react";

export function SubmitButton() {
  const { invoke, loading, error } = useDelocAction("send-slack");

  async function handleClick() {
    const result = await invoke({ message: "Click!" });
    if (result.success) {
      console.log("sent");
    }
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? "Sending…" : "Notify Slack"}
    </button>
  );
}

For frameworks without React, or plain HTML:

ts
import { deloc } from "@deloc/client";

const result = await deloc.action("send-slack", {
  message: "Q3 deck shipped",
});

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error, result.errorType);
}
Same-origin session auth
Actions must be called from the published app URL — that's how the Worker authenticates the viewer. You can't call an action from localhost or a different site. Use deloc actions test for local verification.

Testing

shell
deloc actions test my-dashboard send-slack --body '{"message":"test"}'
deloc actions test my-dashboard refresh-sales     # no body needed if action doesn't take vars

Test invocations fire the real upstream request but are marked with errorType='test' in logs — they don't count toward per-viewer or per-app rate limits and they don't trigger auto-disable. Same as the MCP test_action tool.

Logs and audit

Every invocation — real, tested, or failed — is recorded. Each log row has:

  • Action name and app slug
  • Viewer email (or "publisher"/"admin" for CLI/MCP test invocations)
  • External ID (the value of the variable you named in --external-id-variable, if any)
  • HTTP status and latency
  • Error type (success, error, test, rate_limit, timeout)
  • Error message (truncated to 500 chars)
  • Timestamp
shell
deloc actions logs my-dashboard                           # Recent 50
deloc actions logs my-dashboard --action send-slack       # Filter by name
deloc actions logs my-dashboard --status error --limit 100
deloc actions logs my-dashboard --external-id order-42    # Find a specific invocation

Max 200 rows per fetch. Logs are also visible in the web dashboard's app detail view.

Rate limits and auto-disable

Every action has two rate limits, both enforced at the Worker:

  • Per viewer per hour — default 60. Prevents a single user from hammering the endpoint. Override with --rate-viewer (1–3600).
  • Per app per hour — default 1000. Catches runaway loops. Override with --rate-app (1–100000).

Hitting a rate limit returns a 429 to the browser, logged with errorType='rate_limit'.

Auto-disable kicks in if an action is consistently failing upstream — repeated 4xx/5xx responses flip the action to disabled and log a reason. You'll see the disabled state in deloc actions list or the dashboard. Re-enable with deloc actions enable after fixing the upstream issue.

Tier gating and roadmap

Actions are available on Pro, Pro Unlimited, Team, and Enterprise. Free-tier users see an upgrade screen when they open Actions in the dashboard. No per-action charges — unlimited actions and invocations within your plan's rate-limit ceilings.

Shipped today

  • Full HTTP actions with runtime, secret, and trusted-context templates
  • Encrypted secrets with rotation
  • OAuth credentials (client_credentials, password, jwt_bearer)
  • Testing, logs, rate limits, auto-disable
  • Generic webhook preset

On the roadmap

  • Service-specific presets (Slack, Google Sheets, Linear, HubSpot)
  • Scheduled actions (cron-triggered invocations without a viewer)
  • Action chaining / workflow builder