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:
tis the Unix timestamp when Oura signed the event.v1is 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();
}
});
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
2xxfast. 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 checkamountmatches your order before you ship. - Never trust the browser. A customer returning to
return_urlis a hint, not proof.