Skip to content

Webhooks

Oura tells your server about payment results by sending a signed POST request to your webhook endpoint. Webhooks are the source of truth. Fulfil orders from them, not from the customer's return to your site.

Set your endpoint URL and copy your webhook secret in the dashboard.

Events

Type Sent when
payment.succeeded The payment is complete and the money is in your rail account.
payment.failed The rail declined the payment.
payment.expired Nobody paid before the payment expired.

New event types may be added. Ignore types you don't handle and still return 200.

{
  "id": "evt_3nM1",
  "type": "payment.succeeded",
  "data": { "payment_id": "pay_8Kq2xV" },
  "created_at": "2026-11-08T10:02:41Z"
}

Signatures

Each request has an X-Oura-Signature header:

X-Oura-Signature: t=1762596161,v1=5f2c…e9a1
  • t is the Unix timestamp when Oura signed the event.
  • v1 is the hex HMAC-SHA256 of the string "{t}.{raw_body}", keyed with your webhook secret.

To verify, recompute the HMAC over the raw request body (not re-serialised JSON), compare it in constant time, and reject timestamps older than 5 minutes to stop replays. Every SDK does all of this for you.

Use the raw body

Frameworks that parse JSON before your handler runs change the bytes and break the signature. Read the raw body, as in the examples below.

Verify and handle

import { webhooks } from '@oura/sdk';

app.post('/webhooks/oura', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = webhooks.constructEvent(
      req.body.toString('utf8'),
      req.header('X-Oura-Signature')!,
      process.env.OURA_WEBHOOK_SECRET!,
    );
    if (event.type === 'payment.succeeded') {
      // fulfil the order — event.data.payment_id
    }
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});
// app/api/webhooks/oura/route.ts
import { ouraWebhookRoute } from '@oura/nextjs';

export const runtime = 'nodejs';

export const POST = ouraWebhookRoute({
  secret: process.env.OURA_WEBHOOK_SECRET!,
  onEvent: async (event) => {
    if (event.type === 'payment.succeeded') {
      // fulfil the order — event.data.payment_id
    }
  },
});
from oura import webhooks
from oura.errors import OuraError

@app.post("/webhooks/oura")
def oura_webhook():
    try:
        event = webhooks.construct_event(
            request.get_data(),
            request.headers.get("X-Oura-Signature"),
            os.environ["OURA_WEBHOOK_SECRET"],
        )
    except OuraError:
        return "", 400
    if event["type"] == "payment.succeeded":
        ...  # event["data"]["payment_id"]
    return "", 200
use Oura\Webhooks;
use Oura\OuraError;

$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_OURA_SIGNATURE'] ?? '';

try {
    $event = Webhooks::constructEvent($raw, $sig, getenv('OURA_WEBHOOK_SECRET'));
} catch (OuraError $e) {
    http_response_code(400);
    exit;
}
if ($event['type'] === 'payment.succeeded') {
    // $event['data']['payment_id']
}
http_response_code(200);
func handler(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)
    event, err := oura.ConstructEvent(
        string(raw),
        r.Header.Get("X-Oura-Signature"),
        os.Getenv("OURA_WEBHOOK_SECRET"),
        oura.DefaultToleranceSeconds,
    )
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    if event.Type == "payment.succeeded" {
        // event.Data["payment_id"]
    }
    w.WriteHeader(http.StatusOK)
}
try {
    String payload = Webhooks.constructEvent(rawBody, signatureHeader, webhookSecret);
    JsonObject event = new Gson().fromJson(payload, JsonObject.class);
    if ("payment.succeeded".equals(event.get("type").getAsString())) {
        // event.getAsJsonObject("data").get("payment_id")
    }
    // respond 200
} catch (OuraException e) {
    // respond 400
}
app.MapPost("/webhooks/oura", async (HttpRequest req) =>
{
    using var reader = new StreamReader(req.Body);
    var raw = await reader.ReadToEndAsync();
    var sig = req.Headers["X-Oura-Signature"].ToString();
    try
    {
        var ev = Webhooks.ConstructEvent(raw, sig, Environment.GetEnvironmentVariable("OURA_WEBHOOK_SECRET")!);
        if (ev.GetProperty("type").GetString() == "payment.succeeded")
        {
            // ev.GetProperty("data").GetProperty("payment_id")
        }
        return Results.Ok();
    }
    catch (OuraException)
    {
        return Results.BadRequest();
    }
});
def oura_webhook
  event = Oura::Webhooks.construct_event(
    request.raw_post,
    request.headers['X-Oura-Signature'],
    ENV.fetch('OURA_WEBHOOK_SECRET')
  )
  fulfil(event['data']['payment_id']) if event['type'] == 'payment.succeeded'
  head :ok
rescue Oura::Error
  head :bad_request
end

Each SDK also has a verify(raw, header, secret, tolerance = 300) helper that returns a boolean, if you would rather branch yourself.

Best practices

  • Return 2xx fast. Do slow work (emails, fulfilment) in a background job.
  • Be idempotent. The same event can arrive more than once. Check whether the order is already paid.
  • Confirm the amount. On payment.succeeded, retrieve the payment and check amount matches your order before you ship.
  • Never trust the browser. A customer returning to return_url is a hint, not proof.