# Webhooks

Webhooks deliver real-time notifications when resources change. Instead of polling the API, register a URL and receive `POST` requests whenever events occur. The Dinie API follows the [Standard Webhooks](https://www.standardwebhooks.com/) specification.

## Configuring Webhook Endpoints

### Create an Endpoint

Register a URL to receive webhook events. You can subscribe to specific events or receive all of them.


```typescript Node.js
const endpoint = await dinie.webhooks.endpoints.create({
  url: "https://yourapp.example.com/webhooks/dinie",
  events: ["customer.active", "credit_offer.available", "loan.*"],
  description: "Production webhook",
});

// Store endpoint.secret securely -- it is only displayed once.
console.log(endpoint.secret); // "whsec_xxxxx..."
```


```ruby Ruby
endpoint = dinie.webhooks.endpoints.create(
  url: "https://yourapp.example.com/webhooks/dinie",
  events: ["customer.active", "credit_offer.available", "loan.*"],
  description: "Production webhook"
)

# Store endpoint.secret securely -- it is only displayed once.
puts endpoint.secret  # "whsec_xxxxx..."
```


```python Python
endpoint = dinie.webhooks.endpoints.create(
    url="https://yourapp.example.com/webhooks/dinie",
    events=["customer.active", "credit_offer.available", "loan.*"],
    description="Production webhook",
)

# Store endpoint.secret securely -- it is only displayed once.
print(endpoint.secret)  # "whsec_xxxxx..."
```


```bash cURL
curl -X POST https://sandbox.api.dinie.com.br/v3/webhooks/endpoints \
  -H "Authorization: Bearer dinie_at_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example.com/webhooks/dinie",
    "events": ["customer.active", "credit_offer.available", "loan.*"],
    "description": "Production webhook"
  }'
```

The returned `endpoint.secret` is required to verify signatures -- store it immediately in your secrets manager.

> **Warning:** The `secret` is **only returned on creation and rotation**. Store it immediately in your secrets manager. You need it to verify webhook signatures.


### Managing Endpoints

| Operation | Method | Endpoint |
|  --- | --- | --- |
| List all | `GET` | `/v3/webhooks/endpoints` |
| Get one | `GET` | `/v3/webhooks/endpoints/{id}` |
| Update | `PATCH` | `/v3/webhooks/endpoints/{id}` |
| Delete | `DELETE` | `/v3/webhooks/endpoints/{id}` |


You can disable an endpoint without deleting it by setting `status: "disabled"` via `PATCH`.

## Receiving Webhooks

Each delivery is a `POST` request with authentication headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`). Use the SDK to verify and parse the event:


```typescript Node.js
import express from "express";
import Dinie from "dinie";

const app = express();
const dinie = new Dinie({
  clientId: process.env.DINIE_CLIENT_ID,
  clientSecret: process.env.DINIE_CLIENT_SECRET,
  webhookSecret: process.env.DINIE_WEBHOOK_SECRET,
});

app.post("/webhooks/dinie", express.raw({ type: "application/json" }), (req, res) => {
  const event = dinie.webhooks.unwrap(req.body.toString(), req.headers);

  switch (event.type) {
    case "customer.active":
      console.log(`Customer ${event.data.id} approved`);
      break;
    case "loan.active":
      console.log(`Loan ${event.data.id} disbursed`);
      break;
  }

  res.sendStatus(200);
});
```


```ruby Ruby
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    event = dinie.webhooks.unwrap(request.raw_post, request.headers)

    case event.type
    when "customer.active"
      puts "Customer #{event.data.id} approved"
    when "loan.active"
      puts "Loan #{event.data.id} disbursed"
    end

    head :ok
  end
end
```


```python Python
import os
from flask import Flask, request
from dinie import Dinie

app = Flask(__name__)
dinie = Dinie(
    client_id=os.environ["DINIE_CLIENT_ID"],
    client_secret=os.environ["DINIE_CLIENT_SECRET"],
    webhook_secret=os.environ["DINIE_WEBHOOK_SECRET"],
)

@app.route("/webhooks/dinie", methods=["POST"])
def handle_webhook():
    event = dinie.webhooks.unwrap(
        request.get_data(as_text=True),
        request.headers,
    )

    if event.type == "customer.active":
        print(f"Customer {event.data.id} approved")
    elif event.type == "loan.active":
        print(f"Loan {event.data.id} disbursed")

    return "", 200
```


```bash cURL
# Test your endpoint by sending a simulated webhook payload:
curl -X POST https://yourapp.example.com/webhooks/dinie \
  -H "Content-Type: application/json" \
  -H "webhook-id: msg_550e8400e29b41d4a716446655440000" \
  -H "webhook-timestamp: 1709546400" \
  -H "webhook-signature: v1,K5oZfzN95Z2P2rk2kbX1XISvnJtgBXMiGMKQ0iR1sLA=" \
  -d '{
    "type": "customer.active",
    "timestamp": 1709546400,
    "data": {
      "id": "cust_550e8400...",
      "status": "active"
    }
  }'
```

The `webhook_secret` is configured in the client constructor. The `unwrap` method uses this secret to verify the HMAC-SHA256 signature, validate the timestamp (5-minute replay protection), and return the typed event. If verification fails, an exception is thrown -- return `400` in that case.

## Manual Signature Verification

If you are not using the SDKs, implement verification manually following the [Standard Webhooks](https://www.standardwebhooks.com/) specification:

1. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw_body}`
2. Decode the secret: remove the `whsec_` prefix and base64-decode the remainder
3. Compute the HMAC-SHA256 of the signed content using the decoded secret
4. Base64-encode the result and compare it against each signature in the `webhook-signature` header
5. Reject if the `webhook-timestamp` is older than 5 minutes (replay protection)


See the [Standard Webhooks](https://www.standardwebhooks.com/) specification for full details on headers and signature format.

## Response Codes

Your webhook endpoint must respond within **15 seconds**. Only the HTTP status code matters -- the response body is ignored.

| Status | Behavior | Description |
|  --- | --- | --- |
| **2xx** (200-299) | **Success** | Delivery confirmed. Most common: `200 OK` or `204 No Content`. |
| **3xx** (300-399) | **Failure + retry** | Redirects are NOT followed. Update the endpoint URL via API instead. |
| **410** Gone | **Endpoint disabled** | Dinie permanently disables the endpoint and stops sending events. Use to unsubscribe without calling the API. |
| **429** Too Many Requests | **Throttle** | Dinie reduces send rate and reschedules with backoff. |
| **4xx** (other) | **Failure + retry** | Any other client error triggers retries. |
| **502** Bad Gateway | **Throttle** | Dinie reduces send rate before retrying. |
| **504** Gateway Timeout | **Throttle** | Same behavior as 502. |
| **5xx** (other) | **Failure + retry** | Any other server error triggers retries. |
| Timeout (no response in 15s) | **Failure + retry** | Connection closed, delivery treated as failure. |


## Retry Policy

Failed deliveries are retried with exponential backoff. Random jitter is added to each interval to prevent thundering herd.

| Attempt | Interval |
|  --- | --- |
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 14 hours |
| 9 | 20 hours |
| 10 | 24 hours |


After 10 failed attempts (~3 days), the event is marked as `failed`. Failed events can be resent manually. Repeated failures across multiple events will automatically disable the webhook endpoint.

> **Info:** Use the `webhook-id` header as an idempotency key to deduplicate deliveries on your side. The same event may be delivered more than once during retries.


## Secret Rotation

Rotate your webhook signing secret without downtime:


```bash
curl -X POST https://sandbox.api.dinie.com.br/v3/webhooks/endpoints/we_550e8400.../secret/rotate \
  -H "Authorization: Bearer dinie_at_..." \
  -H "Content-Type: application/json" \
  -d '{ "expire_current_in": 3600 }'
```

During the transition period (`expire_current_in`, default 1 hour, maximum 24 hours), both secrets -- old and new -- are active. The `webhook-signature` header contains signatures for **both secrets**, so your verification code will match either one.

1. Call `POST /v3/webhooks/endpoints/{id}/secret/rotate` with a transition period
2. Update your application with the new secret from the response
3. Deploy -- during the transition period, both secrets work
4. After the transition period, only the new secret is used


## Best Practices

1. **Always verify signatures** -- never trust webhook payloads without verification
2. **Respond quickly** -- return a `200` status within 15 seconds. Process the event asynchronously if needed.
3. **Implement idempotency** -- use the `webhook-id` to deduplicate. Your handler should safely process the same event twice.
4. **Use HTTPS** -- webhook URLs must use HTTPS. HTTP endpoints are rejected.
5. **Log deliveries** -- store the `webhook-id` and `webhook-timestamp` for debugging and audit trails.


## Available Events

All payloads follow the same envelope:


```json
{
  "type": "resource.action",
  "timestamp": 1709546400,
  "data": { ... }
}
```

### Wildcard Subscription

- Specific events: `["customer.active", "loan.created"]`
- All from a resource: `["loan.*"]`
- All events: omit the `events` field or pass an empty array


### Events by Resource

| Resource | Event | Description |
|  --- | --- | --- |
| **Customer** | `customer.created` | New customer registered (status: `creating`) |
|  | `customer.under_review` | KYC submitted, review started |
|  | `customer.kyc_updated` | KYC requirement status changed |
|  | `customer.active` | Fully verified, registration complete |
|  | `customer.denied` | Permanently blocked (fraud, closed company) |
| **Credit Offer** | `credit_offer.available` | New offer created for the customer |
|  | `credit_offer.expired` | Offer passed its expiration date |
| **Loan** | `loan.created` | Loan created with CCB, awaiting signatures |
|  | `loan.signature_received` | Individual signer completed their signature |
|  | `loan.processing` | All signatures collected, disbursement in progress |
|  | `loan.active` | Funds disbursed, repayment started |
|  | `loan.payment_received` | Installment received |
|  | `loan.finished` | Loan fully repaid |
|  | `loan.cancelled` | Loan cancelled |
|  | `loan.error` | Processing or disbursement error |