Recipes

Investigate a failed payment

A read-only support tool: a payment ID in, the cause of the failure out.

Build a support tool that takes a payment ID and answers "why did this payment fail?" from your payments API and your status page. The agent can only read: it cannot refund, retry or change anything. Setup takes about fifteen minutes.

A run like this usually takes 10 to 15 seconds. Most of the cost is compute, billed at $0.0002 per second, and each page the agent fetches costs $0.001. The share of the model depends on the model you choose, so read cost_micros on your first runs. The budget of $0.50 is a ceiling, not a price.

  1. Gather what you need

    • An Ephemeral account with credits, and two API keys from API keys: a member key for your backend, which starts runs, and an admin key for the one-time setup of the connection. See roles and keys.
    • The base URL of your payments API (HTTPS, reachable from the internet) and a read-only key for it, such as a restricted key that can only read payments.
    • A public status page on one domain, here status.example.com.
    • An HTTPS endpoint on your server that will receive the result.
    Terminal
    export EPHEMERAL_URL=https://app.ephemer.al
    export EPHEMERAL_API_KEY=eak_…          # member key, for your backend
    export EPHEMERAL_ADMIN_KEY=eak_…        # admin key, for setup only
    export EPHEMERAL_CALLBACK_SECRET=$(openssl rand -hex 16)   # 32 characters; keep it on your server
  2. Write and upload the playbook

    The playbook is where your team's knowledge goes: which calls to make and how to read the answer. Keep it short and concrete. The agent receives it as a read-only file next to the task.

    playbook.md
    # Failed payment playbook
    
    You can only read. The payments connection allows GET, and web access is limited to status.example.com.
    
    1. GET /payments/{payment_id}. Note status, amount, currency, failure_code and failure_message.
    2. GET /payments/{payment_id}/attempts. Check whether earlier attempts failed for the same reason.
    3. If failure_code is processor_error or timeout, read https://status.example.com for incidents around that time.
    4. Decide the cause:
       - card_declined, insufficient_funds, expired_card: the customer's bank or card. Suggest a retry or another card.
       - processor_error, timeout: ours or the processor's. Say whether the status page shows an incident.
       - fraud_review: a risk rule held it. Do not suggest bypassing it.
    5. Never guess. If a call fails or a field is missing, say so.
    
    Answer in at most six lines: the cause, the evidence (field and value) and the next step for the support agent.
    Do not include card numbers or customer email addresses.
    curl $EPHEMERAL_URL/v1/files \
      -H "Authorization: Bearer $EPHEMERAL_API_KEY" \
      -F [email protected]

    The file is stored under the name playbook.md. Uploading it again adds a new version, and workflows that name the file pick up the latest one when a run starts. See files.

  3. Create the payments-api connection

    A connection holds the API's address and key inside Ephemeral. The agent calls it by name and never sees the key. Set allowed_methods to ["GET"] so that every other method is refused.

    curl -X PUT $EPHEMERAL_URL/v1/connections/payments-api \
      -H "Authorization: Bearer $EPHEMERAL_ADMIN_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "type": "http",
        "url": "https://api.example.com/v1",
        "auth": { "type": "bearer", "token": "rk_live_…" },
        "allowed_methods": ["GET"],
        "instructions": "Look up a payment with GET /payments/{id} and its attempts with GET /payments/{id}/attempts. Amounts are in cents."
      }'

    Prefer the dashboard? Open Connections, choose Add connection and HTTP API, enter the name payments-api and the base URL, pick Bearer token and paste the key, then select only GET under Allowed HTTP methods. The instructions tell the agent which endpoints exist; they are shown to it, so keep secrets out.

    Reading the connection back shows no token, and GET /v1/catalog/connections lists it to members with its type and allowed methods, without the URL or any credential.

  4. Save the workflow

    A workflow keeps the task, the playbook, the connection and the limits under one name, so your backend only sends the payment ID. The agent sees the connection as payments. Web access is limited to the status page, and the shell is off because the task needs no commands.

    curl -X PUT $EPHEMERAL_URL/v1/workflows/investigate-payment \
      -H "Authorization: Bearer $EPHEMERAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "prompt": "Find out why this payment failed.",
        "files": [{ "file": "playbook.md" }],
        "connections": { "payments": { "ref": "payments-api", "allowed_methods": ["GET"] } },
        "web": { "allow": ["status.example.com"] },
        "shell": false,
        "timeout_seconds": 120
      }'

    No model is set, so each run uses the default model at the time it starts; pick another from the model catalog if you prefer. The payment ID, the budget and the callback belong to each run, not to the workflow.

  5. Start a run from your backend

    Send the workflow name, the payment ID, the budget and a callback. The Idempotency-Key makes a retry safe: a repeated key returns the run that already exists with 200 instead of starting a second one. Use a key you can rebuild, such as the payment ID or your ticket ID.

    const paymentId = "pay_123";
    
    const response = await fetch(`${process.env.EPHEMERAL_URL}/v1/runs`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.EPHEMERAL_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `investigate-${paymentId}`,
      },
      body: JSON.stringify({
        workflow: "investigate-payment",
        input: { payment_id: paymentId },
        max_cost_micros: 500000,
        callback: {
          url: "https://example.com/hooks/ephemeral",
          secret: process.env.EPHEMERAL_CALLBACK_SECRET,
        },
      }),
    });
    const run = await response.json(); // { id: "run_…", status: "queued", … }

    The response is 202 Accepted with the run object, in status queued. The run starts as soon as a worker is free. The callback URL must use HTTPS and resolve to a public address, and a secret must be 24 to 1,024 characters; otherwise the request fails with 400 and nothing is started. On the hosted service 402 means your credits do not cover the budget.

  6. Verify the callback

    When the run reaches a final status, Ephemeral sends one signed POST with the run object. Check the signature against the raw bytes of the body before you trust anything in it. The signed text is the X-Ephemeral-Timestamp header, a period and the body, signed with HMAC-SHA256 using your secret. See callbacks for the full scheme.

    Node.js, no dependencies
    import crypto from "node:crypto";
    import http from "node:http";
    
    const handled = new Set(); // use your database in production
    
    http
      .createServer((req, res) => {
        const chunks = [];
        req.on("data", (chunk) => chunks.push(chunk));
        req.on("end", () => {
          const body = Buffer.concat(chunks);
          const timestamp = req.headers["x-ephemeral-timestamp"] ?? "";
          const signature = req.headers["x-ephemeral-signature"] ?? "";
          const expected =
            "sha256=" +
            crypto
              .createHmac("sha256", process.env.EPHEMERAL_CALLBACK_SECRET)
              .update(timestamp + ".")
              .update(body)
              .digest("hex");
    
          const valid =
            signature.length === expected.length &&
            crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
          const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
          if (!valid || !fresh) return res.writeHead(401).end();
    
          const run = JSON.parse(body);
          if (!handled.has(run.id)) {
            handled.add(run.id);
            // Attach run.output to the support ticket for run.id here.
          }
          res.writeHead(204).end(); // any 2xx counts as delivered
        });
      })
      .listen(3000); // serve this behind HTTPS at /hooks/ephemeral

    Any non-2xx answer is retried with growing delays, so the same run can arrive twice. That is why the handler remembers run IDs.

  7. Read the result

    The body of the callback is the run object. For the example payment it looks like this:

    JSON
    {
      "id": "run_118622d16f00105f7bba6d2281774705",
      "status": "succeeded",
      "output": "- **Cause:** The customer’s bank declined the payment for insufficient funds.\n- **Evidence:** `failure_code: insufficient_funds`; `failure_message: The card issuer declined the charge: insufficient funds.`\n- **Attempts:** Both attempts failed for the same reason.\n- **Next step:** Suggest the customer retry after resolving the balance issue or use another card.",
      "created_at": "2026-10-03T09:14:05.118Z",
      "started_at": "2026-10-03T09:14:05.402Z",
      "finished_at": "2026-10-03T09:14:16.447Z",
      "max_cost_micros": 500000,
      "cost_micros": 2618,
      "billing_pending": false,
      "zero_data_retention": false
    }

    output is the agent's answer as text. cost_micros is the whole cost in millionths of a dollar, here $0.002618. A run that did not succeed has status failed, timed_out or cancelled and an error instead of an output; show your agent that rather than nothing. If billing_pending is true, the cost is still provisional: read the run again with GET /v1/runs/{id} a little later. To see each call the agent made, open the run in the dashboard or stream its events.

Why it's safe

RiskWhat stops it
The agent changes or refunds a payment.The connection and the workflow both allow only GET; any other method is refused with HTTP method not permitted.
The payments key leaks.The key stays in Ephemeral. The microVM has no network and no key; the broker adds the key to each request and removes credentials from responses.
Data goes to an unknown site.Web access allows only status.example.com, and redirects are checked against the same list. See web access.
The agent runs commands.shell is false.
A run costs more than expected.The budget of 500,000 micros ($0.50) is reserved at the start and cannot be exceeded; timeout_seconds ends the run after 120 seconds.
Someone forges the result.Your endpoint accepts only requests whose signature verifies.
Give the read-only key only read access
The connection limits what the agent can call, but a key that can only read is the stronger guarantee. Create a restricted key in your payments provider for this connection alone, so that you can revoke it by itself.
Tuning the playbook
Run it on a few known failures and read the output. When it is wrong, change the playbook and upload it again; the next run uses the new file. See workflows for how saved fields and per-run overrides combine.
© 2026 EphemeralProduced by erni.works
esc

Nothing matches “”.