> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dakota.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Integration

Webhooks allow Dakota Platform to send real-time notifications about events in your account directly to your application.

# Overview

Instead of constantly polling our API for updates, webhooks deliver event notifications instantly when something happens:

* Transaction status changes
* Customer onboarding updates
* Account changes
* System events

# Webhook Signature Verification

Dakota signs every webhook with an **Ed25519** signature (not HMAC). To verify a delivery, you need Dakota's **public key** — set it in your environment as `DAKOTA_WEBHOOK_PUBLIC_KEY`.

## Dakota Public Keys

| Environment    | Public Key (hex-encoded)                                           |
| -------------- | ------------------------------------------------------------------ |
| **Production** | `65b797d688ed4991ecc0d922f360bd9b4c3d68e5a36ce2b1307cc8547bd68be4` |
| **Sandbox**    | `7a2f771f3a7ac9ae2a95066df35dc0261d7ce354214736cc232d70b3c66f8a5f` |

Each key is 64 hex characters (32 raw bytes).

## Signature Headers

Dakota includes these headers in every webhook request:

| Header                | Description                                                                                               |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| `X-Webhook-Signature` | Base64-encoded Ed25519 signature over `timestamp + body`                                                  |
| `X-Webhook-Timestamp` | Unix timestamp when the webhook was sent — reject anything older than 5 minutes to prevent replay attacks |
| `X-Dakota-Event-ID`   | Unique identifier for the event (optional, useful for idempotency)                                        |

Full verification implementations for Node.js, Python, Go, Rust, and Java are in [Verification Code Examples](#verification-code-examples) below.

# Setting Up Webhooks

## 1. Create a Webhook Endpoint

Create an endpoint in your application to receive webhook notifications:

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Express.js example
  app.post('/webhooks/dakota', (req, res) => {
    const event = req.body;
    
    // Verify webhook signature (required for security)
    const isValid = verifyWebhookSignature(
      req.headers['x-webhook-signature'],
      req.headers['x-webhook-timestamp'],
      JSON.stringify(req.body),
      process.env.DAKOTA_WEBHOOK_PUBLIC_KEY
    );
    
    if (!isValid) {
      console.error('Webhook signature verification failed');
      return res.status(401).send('Unauthorized');
    }
    
    // Process the event
    handleWebhookEvent(event);
    
    // Respond with 200 to acknowledge receipt
    res.status(200).send('OK');
  });
  ```

  ```python Python theme={null}
  # Flask example
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  @app.route('/webhooks/dakota', methods=['POST'])
  def handle_webhook():
      event = request.get_json()
      
      # Verify webhook signature (required for security)
      signature = request.headers.get('X-Webhook-Signature')
      timestamp = request.headers.get('X-Webhook-Timestamp')
      is_valid = verify_webhook_signature(
          signature,
          timestamp,
          request.get_data(as_text=True),
          os.getenv('DAKOTA_WEBHOOK_PUBLIC_KEY')
      )
      
      if not is_valid:
          print('Webhook signature verification failed')
          return jsonify({'error': 'Unauthorized'}), 401
      
      # Process the event
      handle_webhook_event(event)
      
      # Respond with 200 to acknowledge receipt
      return jsonify({'status': 'ok'}), 200
  ```

  ```go Go theme={null}
  package main

  import (
      "encoding/json"
      "io"
      "log"
      "net/http"
      "os"
  )

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      if r.Method != http.MethodPost {
          http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
          return
      }
      
      // Read request body
      body, err := io.ReadAll(r.Body)
      if err != nil {
          log.Printf("Failed to read request body: %v", err)
          http.Error(w, "Bad request", http.StatusBadRequest)
          return
      }
      defer r.Body.Close()
      
      signature := r.Header.Get("X-Webhook-Signature")
      timestamp := r.Header.Get("X-Webhook-Timestamp")
      
      // Verify webhook signature (required for security)
      isValid := verifyWebhookSignature(
          signature,
          timestamp,
          string(body),
          os.Getenv("DAKOTA_WEBHOOK_PUBLIC_KEY")
      )
      
      if !isValid {
          log.Println("Webhook signature verification failed")
          http.Error(w, "Unauthorized", http.StatusUnauthorized)
          return
      }
      
      // Parse webhook event
      var event map[string]interface{}
      if err := json.Unmarshal(body, &event); err != nil {
          log.Printf("Failed to parse webhook event: %v", err)
          http.Error(w, "Bad request", http.StatusBadRequest)
          return
      }
      
      // Process the event
      handleWebhookEvent(event)
      
      // Respond with 200 to acknowledge receipt
      w.WriteHeader(http.StatusOK)
      w.Write([]byte("OK"))
  }

  func main() {
      http.HandleFunc("/webhooks/dakota", webhookHandler)
      log.Fatal(http.ListenAndServe(":3000", nil))
  }
  ```

  ```rust Rust theme={null}
  use axum::{extract::Request, response::Json, http::StatusCode};
  use serde_json::Value;
  use std::env;

  async fn webhook_handler(req: Request) -> Result<StatusCode, (StatusCode, &'static str)> {
      // Extract headers and body
      let signature = req.headers()
          .get("X-Webhook-Signature")
          .and_then(|h| h.to_str().ok())
          .ok_or((StatusCode::BAD_REQUEST, "Missing signature header"))?;
      
      let timestamp = req.headers()
          .get("X-Webhook-Timestamp")
          .and_then(|h| h.to_str().ok())
          .ok_or((StatusCode::BAD_REQUEST, "Missing timestamp header"))?;
      
      let body = axum::body::to_bytes(req.into_body(), usize::MAX)
          .await
          .map_err(|_| (StatusCode::BAD_REQUEST, "Failed to read body"))?;
      
      let body_str = String::from_utf8(body.to_vec())
          .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UTF-8 in body"))?;
      
      // Verify webhook signature (required for security)
      let public_key = env::var("DAKOTA_WEBHOOK_PUBLIC_KEY")
          .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Missing public key"))?;
      
      let is_valid = verify_webhook_signature(signature, timestamp, &body_str, &public_key)
          .map_err(|_| (StatusCode::UNAUTHORIZED, "Signature verification failed"))?;
      
      if !is_valid {
          return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
      }
      
      // Parse webhook event
      let event: Value = serde_json::from_str(&body_str)
          .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid JSON"))?;
      
      // Process the event
      handle_webhook_event(event)
          .await
          .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process event"))?;
      
      Ok(StatusCode::OK)
  }
  ```

  ```java Java theme={null}
  import org.springframework.web.bind.annotation.*;
  import org.springframework.http.ResponseEntity;
  import org.springframework.http.HttpStatus;
  import jakarta.servlet.http.HttpServletRequest;
  import java.io.IOException;
  import java.util.Map;

  @RestController
  public class WebhookController {
      
      @PostMapping("/webhooks/dakota")
      public ResponseEntity<String> handleWebhook(
              @RequestBody Map<String, Object> event,
              HttpServletRequest request) throws IOException {
          
          String signature = request.getHeader("X-Webhook-Signature");
          String timestamp = request.getHeader("X-Webhook-Timestamp");
          
          // Get raw body for signature verification
          String rawBody = objectMapper.writeValueAsString(event);
          
          // Verify webhook signature (required for security)
          String publicKey = System.getenv("DAKOTA_WEBHOOK_PUBLIC_KEY");
          boolean isValid = verifyWebhookSignature(signature, timestamp, rawBody, publicKey);
          
          if (!isValid) {
              System.err.println("Webhook signature verification failed");
              return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized");
          }
          
          // Process the event
          handleWebhookEvent(event);
          
          // Respond with 200 to acknowledge receipt
          return ResponseEntity.ok("OK");
      }
      
      private void handleWebhookEvent(Map<String, Object> event) {
          // Your event processing logic here
      }
      
      private boolean verifyWebhookSignature(String signature, String timestamp, 
                                            String body, String publicKey) {
          // Signature verification implementation
          return true; // Placeholder
      }
  }
  ```
</CodeGroup>

## 2. Register Your Webhook

[Register your endpoint](/api-reference/webhooks/create-a-webhook-target) with Dakota Platform:

### Webhook Target Fields

| Field         | Type           | Required | Description                                                                    | Example                                                       |
| ------------- | -------------- | -------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------- |
| `url`         | string         | ✅        | HTTPS endpoint to receive webhooks                                             | `"https://your-app.com/webhooks/dakota"`                      |
| `global`      | boolean        | ❌        | Whether webhook receives events for all customers (default: false)             | `false`                                                       |
| `event_types` | array\[string] | ❌        | Array of event types to subscribe to (defaults to all events if not specified) | `["transaction.auto.updated", "customer.kyb_status.updated"]` |

```bash cURL theme={null}
curl -X POST https://api.platform.dakota.xyz/webhooks/targets \
  -H "X-API-Key: your-api-key" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/dakota",
    "global": false,
    "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"]
  }'
```

Response:

```json theme={null}
{
  "data": {
    "id": "wh_1234567890",
    "url": "https://your-app.com/webhooks/dakota",
    "global": false,
    "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"],
    "created_at": "2024-01-15T10:30:00Z"
  }
}
```

<CodeGroup>
  ```javascript JavaScript theme={null}
  const response = await fetch('https://api.platform.dakota.xyz/webhooks/targets', {
    method: 'POST',
    headers: {
      'X-API-Key': 'your-api-key',
      'X-Idempotency-Key': crypto.randomUUID(),
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      url: 'https://your-app.com/webhooks/dakota',
      global: false,
      event_types: ['transaction.auto.updated', 'customer.kyb_status.updated']
    })
  });

  const webhook = await response.json();
  console.log('Created webhook target:', webhook.data.id);
  ```

  ```python Python theme={null}
  import requests
  import uuid

  response = requests.post(
      'https://api.platform.dakota.xyz/webhooks/targets',
      headers={
          'X-API-Key': 'your-api-key',
          'X-Idempotency-Key': str(uuid.uuid4()),
          'Content-Type': 'application/json'
      },
      json={
          'url': 'https://your-app.com/webhooks/dakota',
          'global': False,
          'event_types': ['transaction.auto.updated', 'customer.kyb_status.updated']
      }
  )

  webhook = response.json()
  print(f'Created webhook target: {webhook["data"]["id"]}')
  ```

  ```go Go theme={null}
  package main

  import (
      "bytes"
      "net/http"
      "github.com/google/uuid"
  )

  func main() {
      client := &http.Client{}
      body := bytes.NewBufferString(`{
          "url": "https://your-app.com/webhooks/dakota",
          "global": false,
          "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"]
      }`)
      
      req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/webhooks/targets", body)
      
      req.Header.Add("X-API-Key", "your-api-key")
      req.Header.Add("X-Idempotency-Key", uuid.New().String())
      req.Header.Add("Content-Type", "application/json")
      
      resp, _ := client.Do(req)
      defer resp.Body.Close()
  }
  ```

  ```rust Rust theme={null}
  use reqwest::header::{HeaderMap, HeaderValue};
  use uuid::Uuid;
  use serde_json::json;

  #[tokio::main]
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
      let client = reqwest::Client::new();
      
      let mut headers = HeaderMap::new();
      headers.insert("X-API-Key", HeaderValue::from_static("your-api-key"));
      headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?);
      headers.insert("Content-Type", HeaderValue::from_static("application/json"));
      
      let body = json!({
          "url": "https://your-app.com/webhooks/dakota",
          "global": false,
          "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"]
      });
      
      let response = client
          .post("https://api.platform.dakota.xyz/webhooks/targets")
          .headers(headers)
          .json(&body)
          .send()
          .await?;
      
      Ok(())
  }
  ```

  ```java Java theme={null}
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.net.URI;
  import java.util.UUID;

  public class DakotaWebhookExample {
      public static void main(String[] args) throws Exception {
          HttpClient client = HttpClient.newHttpClient();
          
          String body = """
              {
                "url": "https://your-app.com/webhooks/dakota",
                "global": false,
                "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"]
              }
              """;
          
          HttpRequest request = HttpRequest.newBuilder()
              .uri(URI.create("https://api.platform.dakota.xyz/webhooks/targets"))
              .header("X-API-Key", "your-api-key")
              .header("X-Idempotency-Key", UUID.randomUUID().toString())
              .header("Content-Type", "application/json")
              .POST(HttpRequest.BodyPublishers.ofString(body))
              .build();
              
          HttpResponse<String> response = client.send(request, 
              HttpResponse.BodyHandlers.ofString());
      }
  }
  ```
</CodeGroup>

# Webhook Events

## Transaction Events

Transaction lifecycle events fire for auto-account (onramp/offramp/swap) and one-off transactions. There is **no separate `failed` event** — failure is reflected in the `status` field of the `*.updated` event.

**transaction.auto.created** — emitted when Dakota creates a transaction for an onramp/offramp/swap account (e.g., after detecting a bank deposit). There is no top-level `amount` — amount-like fields live inside `data.object.receipt`.

```json theme={null}
{
  "id": "2tQRvU7tHKLBcd3qXrPp4jYsKXm",
  "type": "transaction.auto.created",
  "created": 1737457500,
  "api_version": "1.0.0",
  "data": {
    "object": {
      "id": "2tQRvvnYkN6edEJUTmF1LzTj2ug",
      "auto_account_id": "2tQRvK9pRzM4nVbW8sHqL5jXmYt",
      "destination_id": "2tQRvD3xFcJ7bKpW9qNsT4hZmYr",
      "type": "onramp",
      "status": "pending",
      "created_at": 1737457500,
      "updated_at": 1737457500,
      "receipt": {
        "input_currency": "USD",
        "output_currency": "USDC",
        "initial_amount": "1000.00",
        "subtotal_amount": "999.00",
        "converted_amount": "999.00",
        "outgoing_amount": "999.00",
        "external_fee": "1.00",
        "client_fee": "0.00",
        "dakota_fee": "0.00",
        "exchange_rate": "1.00"
      },
      "crypto_details": {
        "destination_network_id": "ethereum",
        "destination_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1"
      },
      "sender_details": {
        "sender_type": "individual",
        "sender_account_holder_name": "Jane Doe",
        "sender_bank_name": "Example Bank",
        "sender_routing_number": "021000021",
        "sender_account_number": "123456789",
        "sender_account_type": "checking"
      }
    }
  }
}
```

**transaction.auto.updated** — emitted on every status change or detail update (inspect `data.object.status` to distinguish `processing`, `completed`, `failed`). When a status change triggered the update, the prior value is included under `data.previous_attributes`.

```json theme={null}
{
  "id": "2tQRvB8mTpYxZ4nWgK6sLvHqRdJ",
  "type": "transaction.auto.updated",
  "created": 1737457620,
  "api_version": "1.0.0",
  "data": {
    "object": {
      "id": "2tQRvvnYkN6edEJUTmF1LzTj2ug",
      "auto_account_id": "2tQRvK9pRzM4nVbW8sHqL5jXmYt",
      "destination_id": "2tQRvD3xFcJ7bKpW9qNsT4hZmYr",
      "type": "onramp",
      "status": "completed",
      "created_at": 1737457500,
      "updated_at": 1737457620,
      "completed_at": 1737457620,
      "receipt": {
        "input_currency": "USD",
        "output_currency": "USDC",
        "initial_amount": "1000.00",
        "subtotal_amount": "999.00",
        "converted_amount": "999.00",
        "outgoing_amount": "999.00",
        "external_fee": "1.00",
        "client_fee": "0.00",
        "dakota_fee": "0.00",
        "exchange_rate": "1.00"
      },
      "crypto_details": {
        "destination_network_id": "ethereum",
        "destination_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1",
        "tx_hash": "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
      },
      "sender_details": {
        "sender_type": "individual",
        "sender_account_holder_name": "Jane Doe",
        "sender_bank_name": "Example Bank",
        "sender_routing_number": "021000021",
        "sender_account_number": "123456789",
        "sender_account_type": "checking"
      }
    },
    "previous_attributes": {
      "status": "processing"
    }
  }
}
```

**transaction.one\_off.created** / **transaction.one\_off.updated** — same shape, emitted for single-use transactions created via `POST /transactions/one-off`. Covers both off-ramp (crypto → fiat with `payment_reference` support) and swap (crypto → crypto with a destination network override). The one-off artifact is discarded after the transaction completes.

## Customer Events

**customer.kyb\_status.updated**

```json theme={null}
{
  "event": "customer.kyb_status.updated",
  "data": {
    "customer_id": "31TgvufZK3gDXBcA3BnSeLWiSn7",
    "kyb_status": "active"
  }
}
```

Some `customer.kyb_status.updated` events include an additional `reason_code` field that disambiguates a status change driven by the Proof-of-Address (PoA) flow. The field is present only on PoA-related transitions; on all other status updates it is absent.

| `reason_code` value         | Fires when                                                                                                             |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `pending_proof_of_address`  | The customer is frozen because their rolling 7-day inbound volume crossed the \$3,000 threshold and no PoA is on file. |
| `proof_of_address_rejected` | Compliance rejected a submitted PoA document.                                                                          |
| `proof_of_address_approved` | Compliance approved a submitted PoA, unfreezing the customer.                                                          |

Example payload with `reason_code`:

```json theme={null}
{
  "event": "customer.kyb_status.updated",
  "data": {
    "customer_id": "31TgvufZK3gDXBcA3BnSeLWiSn7",
    "kyb_status": "frozen",
    "reason_code": "pending_proof_of_address"
  }
}
```

**customer.created**

```json theme={null}
{
  "event": "customer.created",
  "data": {
    "id": "31TgvufZK3gDXBcA3BnSeLWiSn7"
  }
}
```

**customer.kyb\_application.submitted**

```json theme={null}
{
  "event": "customer.kyb_application.submitted",
  "data": {
    "customer_id": "31TgvufZK3gDXBcA3BnSeLWiSn7",
    "type": "business"
  }
}
```

# Verification Code Examples

Ed25519 verification implementations — pick your language. See the public keys and required headers in [Webhook Signature Verification](#webhook-signature-verification) above.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');
  const { Buffer } = require('buffer');

  function verifyWebhookSignature(signature, timestamp, payload, dakotaPublicKeyHex) {
    if (!signature || !timestamp || !payload || !dakotaPublicKeyHex) {
      console.error('Missing required parameters for signature verification');
      return false;
    }

    // Check timestamp to prevent replay attacks (within 5 minutes)
    const currentTime = Math.floor(Date.now() / 1000);
    const webhookTime = parseInt(timestamp);
    const timeDifference = Math.abs(currentTime - webhookTime);
    
    if (timeDifference > 300) { // 5 minutes
      console.error('Webhook timestamp too old or too far in future');
      return false;
    }

    // Create the signed payload (timestamp + body)
    const signedPayload = timestamp + payload;
    
    try {
      // Decode the public key from hex and signature from base64
      const publicKeyBuffer = Buffer.from(dakotaPublicKeyHex, 'hex');
      const signatureBuffer = Buffer.from(signature, 'base64');
      
      // Validate key length (Ed25519 public key should be 32 bytes)
      if (publicKeyBuffer.length !== 32) {
        console.error('Invalid Ed25519 public key length:', publicKeyBuffer.length);
        return false;
      }
      
      // Wrap raw Ed25519 key in DER format for Node.js crypto API
      const derPrefix = Buffer.from([
        0x30, 0x2A, // SEQUENCE, 42 bytes
        0x30, 0x05, // SEQUENCE, 5 bytes
        0x06, 0x03, 0x2B, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
        0x03, 0x21, 0x00 // BIT STRING, 33 bytes (including unused bits byte)
      ]);
      const derKey = Buffer.concat([derPrefix, publicKeyBuffer]);
      
      const publicKey = crypto.createPublicKey({
        key: derKey,
        format: 'der',
        type: 'spki'
      });
      
      // Verify the signature using Ed25519
      const isValid = crypto.verify(
        null, // Ed25519 doesn't use a digest algorithm
        Buffer.from(signedPayload, 'utf8'),
        publicKey,
        signatureBuffer
      );
      
      return isValid;
    } catch (error) {
      console.error('Signature verification failed:', error);
      return false;
    }
  }

  // Complete Express.js webhook handler example
  const express = require('express');
  const app = express();

  // Middleware to capture raw body for signature verification
  app.use('/webhooks/dakota', express.raw({ type: 'application/json' }));

  app.post('/webhooks/dakota', (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const rawBody = req.body.toString('utf8');
    
    // Verify webhook signature
    const isValid = verifyWebhookSignature(
      signature,
      timestamp,
      rawBody,
      process.env.DAKOTA_WEBHOOK_PUBLIC_KEY
    );
    
    if (!isValid) {
      console.error('Webhook signature verification failed');
      return res.status(401).send('Unauthorized');
    }
    
    try {
      const event = JSON.parse(rawBody);
      console.log('Received verified webhook:', event.event, event.id);
      
      // Process the event
      handleWebhookEvent(event);
      
      // Respond with 200 to acknowledge receipt
      res.status(200).send('OK');
    } catch (error) {
      console.error('Error processing webhook:', error);
      res.status(400).send('Bad Request');
    }
  });

  function handleWebhookEvent(event) {
    // Make processing idempotent
    if (isEventAlreadyProcessed(event.id)) {
      console.log('Event already processed:', event.id);
      return;
    }
    
    switch (event.event) {
      case 'transaction.auto.updated':
        handleTransactionUpdate(event.data);
        break;
      case 'customer.kyb_status.updated':
        handleCustomerUpdate(event.data);
        break;
      default:
        console.log(`Unknown event type: ${event.event}`);
    }
    
    // Mark as processed
    markEventAsProcessed(event.id);
  }

  function isEventAlreadyProcessed(eventId) {
    // Check your database/cache for this event ID
    // Return true if already processed
    return false;
  }

  function markEventAsProcessed(eventId) {
    // Store the event ID in your database/cache
    console.log('Marking event as processed:', eventId);
  }

  function handleTransactionUpdate(data) {
    console.log('Processing transaction update:', data.id, data.status);
    // Your transaction handling logic here
  }

  function handleCustomerUpdate(data) {
    console.log('Processing customer update:', data.id, data.kyb_status);
    // Your customer handling logic here
  }
  ```

  ```python Python theme={null}
  import base64
  import json
  import time
  import os
  from flask import Flask, request, jsonify
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
  from cryptography.exceptions import InvalidSignature

  def verify_webhook_signature(signature, timestamp, body, dakota_public_key_hex):
      if not all([signature, timestamp, body, dakota_public_key_hex]):
          print('Missing required parameters for signature verification')
          return False
      
      try:
          # Check timestamp to prevent replay attacks (within 5 minutes)
          current_time = int(time.time())
          webhook_time = int(timestamp)
          time_difference = abs(current_time - webhook_time)
          
          if time_difference > 300:  # 5 minutes
              print('Webhook timestamp too old or too far in future')
              return False
          
          # Create the signed payload (timestamp + body)
          signed_payload = timestamp + body
          
          # Decode the public key from hex and signature from base64
          public_key_bytes = bytes.fromhex(dakota_public_key_hex)
          signature_bytes = base64.b64decode(signature)
          
          # Validate key length (Ed25519 public key should be 32 bytes)
          if len(public_key_bytes) != 32:
              print(f'Invalid Ed25519 public key length: {len(public_key_bytes)}')
              return False
          
          # Create Ed25519 public key object
          public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes)
          
          # Verify the signature
          public_key.verify(signature_bytes, signed_payload.encode('utf-8'))
          return True
      except (InvalidSignature, ValueError, Exception) as e:
          print(f'Signature verification failed: {e}')
          return False

  # Complete Flask webhook handler example
  app = Flask(__name__)
  processed_events = set()  # Simple in-memory store (use database in production)

  @app.route('/webhooks/dakota', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('X-Webhook-Signature')
      timestamp = request.headers.get('X-Webhook-Timestamp')
      raw_body = request.get_data(as_text=True)
      
      # Verify webhook signature
      is_valid = verify_webhook_signature(
          signature,
          timestamp,
          raw_body,
          os.getenv('DAKOTA_WEBHOOK_PUBLIC_KEY')
      )
      
      if not is_valid:
          print('Webhook signature verification failed')
          return jsonify({'error': 'Unauthorized'}), 401
      
      try:
          event = json.loads(raw_body)
          print(f'Received verified webhook: {event.get("event")} {event.get("id")}')
          
          # Process the event
          handle_webhook_event(event)
          
          # Respond with 200 to acknowledge receipt
          return jsonify({'status': 'ok'}), 200
      except (json.JSONDecodeError, KeyError) as e:
          print(f'Error processing webhook: {e}')
          return jsonify({'error': 'Bad Request'}), 400

  def handle_webhook_event(event):
      event_id = event.get('id')
      
      # Make processing idempotent
      if is_event_already_processed(event_id):
          print(f'Event already processed: {event_id}')
          return
      
      event_type = event.get('event')
      event_data = event.get('data', {})
      
      if event_type == 'transaction.auto.updated':
          handle_transaction_update(event_data)
      elif event_type == 'customer.kyb_status.updated':
          handle_customer_update(event_data)
      else:
          print(f'Unknown event type: {event_type}')
      
      # Mark as processed
      mark_event_as_processed(event_id)

  def is_event_already_processed(event_id):
      # Check your database/cache for this event ID
      return event_id in processed_events

  def mark_event_as_processed(event_id):
      # Store the event ID in your database/cache
      processed_events.add(event_id)
      print(f'Marking event as processed: {event_id}')

  def handle_transaction_update(data):
      transaction_id = data.get('id')
      status = data.get('status')
      print(f'Processing transaction update: {transaction_id} -> {status}')
      # Your transaction handling logic here

  def handle_customer_update(data):
      customer_id = data.get('id')
      kyb_status = data.get('kyb_status')
      print(f'Processing customer update: {customer_id} -> {kyb_status}')
      # Your customer handling logic here

  if __name__ == '__main__':
      app.run(port=3000, debug=True)
  ```

  ```rust Rust theme={null}
  use ed25519_dalek::{Signature, Verifier, VerifyingKey};
  use base64::{Engine as _, engine::general_purpose};
  use std::time::{SystemTime, UNIX_EPOCH};
  use warp::Filter;
  use serde_json::{Value, json};
  use std::collections::HashSet;
  use std::sync::{Arc, Mutex};
  use std::convert::Infallible;

  #[derive(Debug)]
  pub struct WebhookVerifier {
      public_key: VerifyingKey,
      processed_events: Arc<Mutex<HashSet<String>>>,
  }

  impl WebhookVerifier {
      pub fn new(public_key_hex: &str) -> Result<Self, Box<dyn std::error::Error>> {
          let public_key_bytes = hex::decode(public_key_hex)?;
          
          // Validate key length (Ed25519 public key should be 32 bytes)
          if public_key_bytes.len() != 32 {
              return Err(format!("Invalid Ed25519 public key length: {}", public_key_bytes.len()).into());
          }
          
          let public_key = VerifyingKey::from_bytes(
              &public_key_bytes.try_into().map_err(|_| "Invalid key length")?
          )?;
          
          Ok(Self {
              public_key,
              processed_events: Arc::new(Mutex::new(HashSet::new())),
          })
      }
      
      pub fn verify_signature(&self, signature: &str, timestamp: &str, body: &str) -> Result<bool, Box<dyn std::error::Error>> {
          if signature.is_empty() || timestamp.is_empty() || body.is_empty() {
              println!("Missing required parameters for signature verification");
              return Ok(false);
          }
          
          // Check timestamp to prevent replay attacks (within 5 minutes)
          let current_time = SystemTime::now()
              .duration_since(UNIX_EPOCH)?
              .as_secs();
          let webhook_time: u64 = timestamp.parse()?;
          let time_difference = (current_time as i64 - webhook_time as i64).abs();
          
          if time_difference > 300 {
              println!("Webhook timestamp too old or too far in future");
              return Ok(false);
          }
          
          // Create the signed payload (timestamp + body)
          let signed_payload = format!("{}{}", timestamp, body);
          
          // Decode signature
          let signature_bytes = general_purpose::STANDARD.decode(signature)?;
          let signature = Signature::from_bytes(&signature_bytes.try_into().map_err(|_| "Invalid signature length")?);
          
          // Verify signature
          match self.public_key.verify(signed_payload.as_bytes(), &signature) {
              Ok(_) => Ok(true),
              Err(e) => {
                  println!("Signature verification failed: {}", e);
                  Ok(false)
              }
          }
      }
      
      pub fn is_event_processed(&self, event_id: &str) -> bool {
          self.processed_events.lock().unwrap().contains(event_id)
      }
      
      pub fn mark_event_processed(&self, event_id: &str) {
          self.processed_events.lock().unwrap().insert(event_id.to_string());
          println!("Marking event as processed: {}", event_id);
      }
      
      pub fn handle_webhook_event(&self, event: &Value) -> Result<(), Box<dyn std::error::Error>> {
          let event_id = event["id"].as_str().unwrap_or("");
          
          // Make processing idempotent
          if self.is_event_processed(event_id) {
              println!("Event already processed: {}", event_id);
              return Ok(());
          }
          
          let event_type = event["event"].as_str().unwrap_or("");
          let event_data = &event["data"];
          
          match event_type {
              "transaction.auto.updated" => {
                  self.handle_transaction_update(event_data)?;
              }
              "customer.kyb_status.updated" => {
                  self.handle_customer_update(event_data)?;
              }
              _ => {
                  println!("Unknown event type: {}", event_type);
              }
          }
          
          // Mark as processed
          self.mark_event_processed(event_id);
          Ok(())
      }
      
      fn handle_transaction_update(&self, data: &Value) -> Result<(), Box<dyn std::error::Error>> {
          let transaction_id = data["id"].as_str().unwrap_or("");
          let status = data["status"].as_str().unwrap_or("");
          println!("Processing transaction update: {} -> {}", transaction_id, status);
          // Your transaction handling logic here
          Ok(())
      }
      
      fn handle_customer_update(&self, data: &Value) -> Result<(), Box<dyn std::error::Error>> {
          let customer_id = data["id"].as_str().unwrap_or("");
          let kyb_status = data["kyb_status"].as_str().unwrap_or("");
          println!("Processing customer update: {} -> {}", customer_id, kyb_status);
          // Your customer handling logic here
          Ok(())
      }
  }

  // Complete Warp webhook handler example
  #[tokio::main]
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
      let public_key_hex = std::env::var("DAKOTA_WEBHOOK_PUBLIC_KEY")
          .expect("DAKOTA_WEBHOOK_PUBLIC_KEY environment variable is required");
      
      let verifier = Arc::new(WebhookVerifier::new(&public_key_hex)?);
      
      let webhook_route = warp::path("webhooks")
          .and(warp::path("dakota"))
          .and(warp::post())
          .and(warp::header::<String>("x-webhook-signature"))
          .and(warp::header::<String>("x-webhook-timestamp"))
          .and(warp::body::bytes())
          .and_then({
              let verifier = verifier.clone();
              move |signature: String, timestamp: String, body: bytes::Bytes| {
                  let verifier = verifier.clone();
                  async move {
                      handle_webhook(verifier, signature, timestamp, body).await
                  }
              }
          });
      
      let port = std::env::var("PORT")
          .unwrap_or_else(|_| "3000".to_string())
          .parse::<u16>()
          .unwrap_or(3000);
      
      println!("Starting webhook server on port {}", port);
      warp::serve(webhook_route)
          .run(([0, 0, 0, 0], port))
          .await;
      
      Ok(())
  }

  async fn handle_webhook(
      verifier: Arc<WebhookVerifier>,
      signature: String,
      timestamp: String,
      body: bytes::Bytes,
  ) -> Result<impl warp::Reply, Infallible> {
      let body_str = match String::from_utf8(body.to_vec()) {
          Ok(s) => s,
          Err(_) => {
              return Ok(warp::reply::with_status(
                  "Invalid UTF-8 in body",
                  warp::http::StatusCode::BAD_REQUEST,
              ));
          }
      };
      
      // Verify webhook signature
      let is_valid = match verifier.verify_signature(&signature, &timestamp, &body_str) {
          Ok(valid) => valid,
          Err(e) => {
              println!("Signature verification error: {}", e);
              false
          }
      };
      
      if !is_valid {
          println!("Webhook signature verification failed");
          return Ok(warp::reply::with_status(
              "Unauthorized",
              warp::http::StatusCode::UNAUTHORIZED,
          ));
      }
      
      // Parse webhook event
      let event: Value = match serde_json::from_str(&body_str) {
          Ok(e) => e,
          Err(e) => {
              println!("Failed to parse webhook event: {}", e);
              return Ok(warp::reply::with_status(
                  "Bad Request",
                  warp::http::StatusCode::BAD_REQUEST,
              ));
          }
      };
      
      println!("Received verified webhook: {} {}", 
          event["event"].as_str().unwrap_or("unknown"), 
          event["id"].as_str().unwrap_or("unknown")
      );
      
      // Process the event
      if let Err(e) = verifier.handle_webhook_event(&event) {
          println!("Error processing webhook event: {}", e);
          return Ok(warp::reply::with_status(
              "Internal Server Error",
              warp::http::StatusCode::INTERNAL_SERVER_ERROR,
          ));
      }
      
      Ok(warp::reply::with_status(
          "OK",
          warp::http::StatusCode::OK,
      ))
  }
  ```

  ```java Java theme={null}
  import java.security.*;
  import java.security.spec.X509EncodedKeySpec;
  import java.util.Base64;
  import java.util.Set;
  import java.util.concurrent.ConcurrentHashMap;
  import java.time.Instant;
  import java.nio.charset.StandardCharsets;

  import org.springframework.web.bind.annotation.*;
  import org.springframework.http.ResponseEntity;
  import org.springframework.http.HttpStatus;
  import jakarta.servlet.http.HttpServletRequest;
  import java.io.IOException;
  import java.util.Map;

  public class WebhookVerifier {
      private final PublicKey publicKey;
      private final Set<String> processedEvents = ConcurrentHashMap.newKeySet();
      
      public WebhookVerifier(String publicKeyHex) throws Exception {
          byte[] publicKeyBytes = hexToBytes(publicKeyHex);
          
          // Validate key length (Ed25519 public key should be 32 bytes)
          if (publicKeyBytes.length != 32) {
              throw new IllegalArgumentException("Invalid Ed25519 public key length: " + publicKeyBytes.length);
          }
          
          // Raw Ed25519 key - wrap in DER format for Java crypto API
          byte[] derKey = wrapRawKeyInDER(publicKeyBytes);
          X509EncodedKeySpec keySpec = new X509EncodedKeySpec(derKey);
          KeyFactory keyFactory = KeyFactory.getInstance("EdDSA");
          this.publicKey = keyFactory.generatePublic(keySpec);
      }
      
      private byte[] hexToBytes(String hex) {
          int len = hex.length();
          byte[] data = new byte[len / 2];
          for (int i = 0; i < len; i += 2) {
              data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
                                  + Character.digit(hex.charAt(i+1), 16));
          }
          return data;
      }
      
      private byte[] wrapRawKeyInDER(byte[] rawKey) {
          // Ed25519 DER prefix for public key
          byte[] derPrefix = {
              0x30, 0x2A, // SEQUENCE, 42 bytes
              0x30, 0x05, // SEQUENCE, 5 bytes
              0x06, 0x03, 0x2B, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
              0x03, 0x21, 0x00 // BIT STRING, 33 bytes (including unused bits byte)
          };
          
          byte[] derKey = new byte[derPrefix.length + rawKey.length];
          System.arraycopy(derPrefix, 0, derKey, 0, derPrefix.length);
          System.arraycopy(rawKey, 0, derKey, derPrefix.length, rawKey.length);
          
          return derKey;
      }
      
      public boolean verifySignature(String signature, String timestamp, String body) {
          if (signature == null || timestamp == null || body == null || 
              signature.isEmpty() || timestamp.isEmpty() || body.isEmpty()) {
              System.err.println("Missing required parameters for signature verification");
              return false;
          }
          
          try {
              // Check timestamp to prevent replay attacks (within 5 minutes)
              long currentTime = Instant.now().getEpochSecond();
              long webhookTime = Long.parseLong(timestamp);
              long timeDifference = Math.abs(currentTime - webhookTime);
              
              if (timeDifference > 300) { // 5 minutes
                  System.err.println("Webhook timestamp too old or too far in future");
                  return false;
              }
              
              // Create the signed payload (timestamp + body)
              String signedPayload = timestamp + body;
              
              // Decode signature
              byte[] signatureBytes = Base64.getDecoder().decode(signature);
              
              // Verify signature using Ed25519
              Signature verifier = Signature.getInstance("EdDSA");
              verifier.initVerify(publicKey);
              verifier.update(signedPayload.getBytes(StandardCharsets.UTF_8));
              
              return verifier.verify(signatureBytes);
              
          } catch (Exception e) {
              System.err.println("Signature verification failed: " + e.getMessage());
              return false;
          }
      }
      
      public boolean isEventProcessed(String eventId) {
          return processedEvents.contains(eventId);
      }
      
      public void markEventProcessed(String eventId) {
          processedEvents.add(eventId);
          System.out.println("Marking event as processed: " + eventId);
      }
      
      public void handleWebhookEvent(Map<String, Object> event) throws Exception {
          String eventId = (String) event.get("id");
          
          // Make processing idempotent
          if (isEventProcessed(eventId)) {
              System.out.println("Event already processed: " + eventId);
              return;
          }
          
          String eventType = (String) event.get("event");
          @SuppressWarnings("unchecked")
          Map<String, Object> eventData = (Map<String, Object>) event.get("data");
          
          switch (eventType) {
              case "transaction.auto.updated":
                  handleTransactionUpdate(eventData);
                  break;
              case "customer.kyb_status.updated":
                  handleCustomerUpdate(eventData);
                  break;
              default:
                  System.out.println("Unknown event type: " + eventType);
          }
          
          // Mark as processed
          markEventProcessed(eventId);
      }
      
      private void handleTransactionUpdate(Map<String, Object> data) {
          String transactionId = (String) data.get("id");
          String status = (String) data.get("status");
          System.out.println("Processing transaction update: " + transactionId + " -> " + status);
          // Your transaction handling logic here
      }
      
      private void handleCustomerUpdate(Map<String, Object> data) {
          String customerId = (String) data.get("id");
          String kybStatus = (String) data.get("kyb_status");
          System.out.println("Processing customer update: " + customerId + " -> " + kybStatus);
          // Your customer handling logic here
      }
  }

  @RestController
  public class WebhookController {
      private final WebhookVerifier webhookVerifier;
      
      public WebhookController() throws Exception {
          String publicKeyHex = System.getenv("DAKOTA_WEBHOOK_PUBLIC_KEY");
          if (publicKeyHex == null || publicKeyHex.isEmpty()) {
              throw new IllegalStateException("DAKOTA_WEBHOOK_PUBLIC_KEY environment variable is required");
          }
          this.webhookVerifier = new WebhookVerifier(publicKeyHex);
      }
      
      @PostMapping("/webhooks/dakota")
      public ResponseEntity<String> handleWebhook(
              @RequestBody Map<String, Object> event,
              HttpServletRequest request) throws IOException {
          
          String signature = request.getHeader("X-Webhook-Signature");
          String timestamp = request.getHeader("X-Webhook-Timestamp");
          
          // Get raw body for signature verification
          String rawBody = new com.fasterxml.jackson.databind.ObjectMapper()
              .writeValueAsString(event);
          
          // Verify webhook signature
          boolean isValid = webhookVerifier.verifySignature(signature, timestamp, rawBody);
          
          if (!isValid) {
              System.err.println("Webhook signature verification failed");
              return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized");
          }
          
          System.out.println("Received verified webhook: " + 
              event.get("event") + " " + event.get("id"));
          
          try {
              // Process the event
              webhookVerifier.handleWebhookEvent(event);
              
              // Respond with 200 to acknowledge receipt
              return ResponseEntity.ok("OK");
          } catch (Exception e) {
              System.err.println("Error processing webhook event: " + e.getMessage());
              return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                  .body("Internal Server Error");
          }
      }
  }
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/ed25519"
      "encoding/base64"
      "encoding/json"
      "fmt"
      "io"
      "log"
      "net/http"
      "os"
      "strconv"
      "sync"
      "time"
  )

  type WebhookEvent struct {
      ID        string                 `json:"id"`
      Event     string                 `json:"event"`
      Data      map[string]interface{} `json:"data"`
      CreatedAt string                 `json:"created_at"`
  }

  type WebhookHandler struct {
      publicKey       ed25519.PublicKey
      processedEvents map[string]bool
      mutex          sync.RWMutex
  }

  func NewWebhookHandler(publicKeyHex string) (*WebhookHandler, error) {
      publicKeyBytes, err := hex.DecodeString(publicKeyHex)
      if err != nil {
          return nil, fmt.Errorf("failed to decode public key: %v", err)
      }
      
      // Validate key length (Ed25519 public key should be 32 bytes)
      if len(publicKeyBytes) != 32 {
          return nil, fmt.Errorf("invalid Ed25519 public key length: %d", len(publicKeyBytes))
      }
      
      return &WebhookHandler{
          publicKey:       ed25519.PublicKey(publicKeyBytes),
          processedEvents: make(map[string]bool),
      }, nil
  }

  func (wh *WebhookHandler) verifySignature(signature, timestamp, body string) bool {
      if signature == "" || timestamp == "" || body == "" {
          log.Println("Missing required parameters for signature verification")
          return false
      }
      
      // Check timestamp to prevent replay attacks (within 5 minutes)
      webhookTime, err := strconv.ParseInt(timestamp, 10, 64)
      if err != nil {
          log.Printf("Invalid timestamp format: %v", err)
          return false
      }
      
      currentTime := time.Now().Unix()
      timeDifference := currentTime - webhookTime
      if timeDifference > 300 || timeDifference < -300 { // 5 minutes
          log.Println("Webhook timestamp too old or too far in future")
          return false
      }
      
      // Create the signed payload
      signedPayload := timestamp + body
      
      // Decode signature
      signatureBytes, err := base64.StdEncoding.DecodeString(signature)
      if err != nil {
          log.Printf("Failed to decode signature: %v", err)
          return false
      }
      
      // Verify signature
      return ed25519.Verify(wh.publicKey, []byte(signedPayload), signatureBytes)
  }

  func (wh *WebhookHandler) isEventProcessed(eventID string) bool {
      wh.mutex.RLock()
      defer wh.mutex.RUnlock()
      return wh.processedEvents[eventID]
  }

  func (wh *WebhookHandler) markEventProcessed(eventID string) {
      wh.mutex.Lock()
      defer wh.mutex.Unlock()
      wh.processedEvents[eventID] = true
      log.Printf("Marking event as processed: %s", eventID)
  }

  func (wh *WebhookHandler) handleWebhookEvent(event WebhookEvent) {
      // Make processing idempotent
      if wh.isEventProcessed(event.ID) {
          log.Printf("Event already processed: %s", event.ID)
          return
      }
      
      switch event.Event {
      case "transaction.auto.updated":
          wh.handleTransactionUpdate(event.Data)
      case "customer.kyb_status.updated":
          wh.handleCustomerUpdate(event.Data)
      default:
          log.Printf("Unknown event type: %s", event.Event)
      }
      
      // Mark as processed
      wh.markEventProcessed(event.ID)
  }

  func (wh *WebhookHandler) handleTransactionUpdate(data map[string]interface{}) {
      transactionID, _ := data["id"].(string)
      status, _ := data["status"].(string)
      log.Printf("Processing transaction update: %s -> %s", transactionID, status)
      // Your transaction handling logic here
  }

  func (wh *WebhookHandler) handleCustomerUpdate(data map[string]interface{}) {
      customerID, _ := data["id"].(string)
      kybStatus, _ := data["kyb_status"].(string)
      log.Printf("Processing customer update: %s -> %s", customerID, kybStatus)
      // Your customer handling logic here
  }

  func (wh *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
      if r.Method != http.MethodPost {
          http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
          return
      }
      
      // Read request body
      body, err := io.ReadAll(r.Body)
      if err != nil {
          log.Printf("Failed to read request body: %v", err)
          http.Error(w, "Bad request", http.StatusBadRequest)
          return
      }
      defer r.Body.Close()
      
      signature := r.Header.Get("X-Webhook-Signature")
      timestamp := r.Header.Get("X-Webhook-Timestamp")
      
      // Verify webhook signature
      if !wh.verifySignature(signature, timestamp, string(body)) {
          log.Println("Webhook signature verification failed")
          http.Error(w, "Unauthorized", http.StatusUnauthorized)
          return
      }
      
      // Parse webhook event
      var event WebhookEvent
      if err := json.Unmarshal(body, &event); err != nil {
          log.Printf("Failed to parse webhook event: %v", err)
          http.Error(w, "Bad request", http.StatusBadRequest)
          return
      }
      
      log.Printf("Received verified webhook: %s %s", event.Event, event.ID)
      
      // Process the event
      wh.handleWebhookEvent(event)
      
      // Respond with 200 to acknowledge receipt
      w.WriteHeader(http.StatusOK)
      w.Write([]byte("OK"))
  }

  func main() {
      publicKeyHex := os.Getenv("DAKOTA_WEBHOOK_PUBLIC_KEY")
      if publicKeyHex == "" {
          log.Fatal("DAKOTA_WEBHOOK_PUBLIC_KEY environment variable is required")
      }
      
      webhookHandler, err := NewWebhookHandler(publicKeyHex)
      if err != nil {
          log.Fatalf("Failed to create webhook handler: %v", err)
      }
      
      http.Handle("/webhooks/dakota", webhookHandler)
      
      port := os.Getenv("PORT")
      if port == "" {
          port = "3000"
      }
      
      log.Printf("Starting webhook server on port %s", port)
      log.Fatal(http.ListenAndServe(":"+port, nil))
  }
  ```
</CodeGroup>

# Managing Webhooks

## List Webhooks

[List all webhook targets](/api-reference/webhooks/list-webhook-targets) configured for your account:

```bash cURL theme={null}
curl -X GET https://api.platform.dakota.xyz/webhooks/targets \
  -H "X-API-Key: your-api-key"
```

<CodeGroup>
  ```javascript JavaScript theme={null}
  const response = await fetch('https://api.platform.dakota.xyz/webhooks/targets', {
    headers: {
      'X-API-Key': 'your-api-key'
    }
  });

  const webhooks = await response.json();
  console.log(`Found ${webhooks.data.length} webhook targets`);
  ```

  ```python Python theme={null}
  import requests

  response = requests.get(
      'https://api.platform.dakota.xyz/webhooks/targets',
      headers={'X-API-Key': 'your-api-key'}
  )

  webhooks = response.json()
  print(f'Found {len(webhooks["data"])} webhook targets')
  ```

  ```go Go theme={null}
  package main

  import (
      "net/http"
  )

  func main() {
      client := &http.Client{}
      req, _ := http.NewRequest("GET", "https://api.platform.dakota.xyz/webhooks/targets", nil)
      
      req.Header.Add("X-API-Key", "your-api-key")
      
      resp, _ := client.Do(req)
      defer resp.Body.Close()
  }
  ```

  ```rust Rust theme={null}
  use reqwest::header::{HeaderMap, HeaderValue};

  #[tokio::main]
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
      let client = reqwest::Client::new();
      
      let mut headers = HeaderMap::new();
      headers.insert("X-API-Key", HeaderValue::from_static("your-api-key"));
      
      let response = client
          .get("https://api.platform.dakota.xyz/webhooks/targets")
          .headers(headers)
          .send()
          .await?;
      
      Ok(())
  }
  ```

  ```java Java theme={null}
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.net.URI;

  public class DakotaListWebhooksExample {
      public static void main(String[] args) throws Exception {
          HttpClient client = HttpClient.newHttpClient();
          
          HttpRequest request = HttpRequest.newBuilder()
              .uri(URI.create("https://api.platform.dakota.xyz/webhooks/targets"))
              .header("X-API-Key", "your-api-key")
              .GET()
              .build();
              
          HttpResponse<String> response = client.send(request, 
              HttpResponse.BodyHandlers.ofString());
      }
  }
  ```
</CodeGroup>

## Update Webhook

[Update an existing webhook target](/api-reference/webhooks/update-a-webhook-target):

```bash cURL theme={null}
curl -X PATCH https://api.platform.dakota.xyz/webhooks/targets/{webhook_id} \
  -H "X-API-Key: your-api-key" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "global": false
  }'
```

<CodeGroup>
  ```javascript JavaScript theme={null}
  const response = await fetch(`https://api.platform.dakota.xyz/webhooks/targets/${webhookId}`, {
    method: 'PATCH',
    headers: {
      'X-API-Key': 'your-api-key',
      'X-Idempotency-Key': crypto.randomUUID(),
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      global: false
    })
  });
  ```

  ```python Python theme={null}
  import requests
  import uuid

  response = requests.patch(
      f'https://api.platform.dakota.xyz/webhooks/targets/{webhook_id}',
      headers={
          'X-API-Key': 'your-api-key',
          'X-Idempotency-Key': str(uuid.uuid4()),
          'Content-Type': 'application/json'
      },
      json={'global': False}
  )
  ```

  ```go Go theme={null}
  package main

  import (
      "bytes"
      "net/http"
      "github.com/google/uuid"
  )

  func main() {
      client := &http.Client{}
      body := bytes.NewBufferString(`{"global": false}`)
      
      req, _ := http.NewRequest("PATCH", "https://api.platform.dakota.xyz/webhooks/targets/" + webhookId, body)
      
      req.Header.Add("X-API-Key", "your-api-key")
      req.Header.Add("X-Idempotency-Key", uuid.New().String())
      req.Header.Add("Content-Type", "application/json")
      
      resp, _ := client.Do(req)
      defer resp.Body.Close()
  }
  ```

  ```rust Rust theme={null}
  use reqwest::header::{HeaderMap, HeaderValue};
  use uuid::Uuid;
  use serde_json::json;

  #[tokio::main]
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
      let client = reqwest::Client::new();
      
      let mut headers = HeaderMap::new();
      headers.insert("X-API-Key", HeaderValue::from_static("your-api-key"));
      headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?);
      headers.insert("Content-Type", HeaderValue::from_static("application/json"));
      
      let body = json!({"global": false});
      
      let response = client
          .patch(&format!("https://api.platform.dakota.xyz/webhooks/targets/{}", webhook_id))
          .headers(headers)
          .json(&body)
          .send()
          .await?;
      
      Ok(())
  }
  ```

  ```java Java theme={null}
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.net.URI;
  import java.util.UUID;

  public class DakotaUpdateWebhookExample {
      public static void main(String[] args) throws Exception {
          HttpClient client = HttpClient.newHttpClient();
          
          String body = "{\"global\": false}";
          
          HttpRequest request = HttpRequest.newBuilder()
              .uri(URI.create("https://api.platform.dakota.xyz/webhooks/targets/" + webhookId))
              .header("X-API-Key", "your-api-key")
              .header("X-Idempotency-Key", UUID.randomUUID().toString())
              .header("Content-Type", "application/json")
              .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
              .build();
              
          HttpResponse<String> response = client.send(request, 
              HttpResponse.BodyHandlers.ofString());
      }
  }
  ```
</CodeGroup>

## Delete Webhook

[Delete a webhook target](/api-reference/webhooks/delete-a-webhook-target):

```bash cURL theme={null}
curl -X DELETE https://api.platform.dakota.xyz/webhooks/targets/{webhook_id} \
  -H "X-API-Key: your-api-key" \
  -H "X-Idempotency-Key: $(uuidgen)"
```

<CodeGroup>
  ```javascript JavaScript theme={null}
  const response = await fetch(`https://api.platform.dakota.xyz/webhooks/targets/${webhookId}`, {
    method: 'DELETE',
    headers: {
      'X-API-Key': 'your-api-key',
      'X-Idempotency-Key': crypto.randomUUID()
    }
  });

  if (response.ok) {
    console.log('Webhook deleted successfully');
  }
  ```

  ```python Python theme={null}
  import requests
  import uuid

  response = requests.delete(
      f'https://api.platform.dakota.xyz/webhooks/targets/{webhook_id}',
      headers={
          'X-API-Key': 'your-api-key',
          'X-Idempotency-Key': str(uuid.uuid4())
      }
  )

  if response.status_code == 204:
      print('Webhook deleted successfully')
  ```

  ```go Go theme={null}
  package main

  import (
      "net/http"
      "github.com/google/uuid"
  )

  func main() {
      client := &http.Client{}
      req, _ := http.NewRequest("DELETE", "https://api.platform.dakota.xyz/webhooks/targets/" + webhookId, nil)
      
      req.Header.Add("X-API-Key", "your-api-key")
      req.Header.Add("X-Idempotency-Key", uuid.New().String())
      
      resp, _ := client.Do(req)
      defer resp.Body.Close()
  }
  ```

  ```rust Rust theme={null}
  use reqwest::header::{HeaderMap, HeaderValue};
  use uuid::Uuid;

  #[tokio::main]
  async fn main() -> Result<(), Box<dyn std::error::Error>> {
      let client = reqwest::Client::new();
      
      let mut headers = HeaderMap::new();
      headers.insert("X-API-Key", HeaderValue::from_static("your-api-key"));
      headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?);
      
      let response = client
          .delete(&format!("https://api.platform.dakota.xyz/webhooks/targets/{}", webhook_id))
          .headers(headers)
          .send()
          .await?;
      
      Ok(())
  }
  ```

  ```java Java theme={null}
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.net.URI;
  import java.util.UUID;

  public class DakotaDeleteWebhookExample {
      public static void main(String[] args) throws Exception {
          HttpClient client = HttpClient.newHttpClient();
          
          HttpRequest request = HttpRequest.newBuilder()
              .uri(URI.create("https://api.platform.dakota.xyz/webhooks/targets/" + webhookId))
              .header("X-API-Key", "your-api-key")
              .header("X-Idempotency-Key", UUID.randomUUID().toString())
              .DELETE()
              .build();
              
          HttpResponse<String> response = client.send(request, 
              HttpResponse.BodyHandlers.ofString());
      }
  }
  ```
</CodeGroup>

# Webhook Delivery

## Retry Policy

Dakota automatically retries failed webhook deliveries using exponential backoff over approximately 48 hours:

| Attempt    | Delay After Failure |
| ---------- | ------------------- |
| 1          | 5 minutes           |
| 2          | 10 minutes          |
| 3          | 15 minutes          |
| 4          | 30 minutes          |
| 5          | 1 hour              |
| 6          | 2 hours             |
| 7          | 4 hours             |
| 8          | 8 hours             |
| 9          | 12 hours            |
| 10 (final) | 20 hours            |

**Retry behavior:**

* **Max attempts**: 10 total
* **Total retry window**: \~48 hours
* **Timeout per attempt**: 20 seconds

## Success Criteria

A webhook delivery is considered **successful** when:

* Your endpoint returns an HTTP status code in the `2xx` range (200-299)
* Response is received within 20 seconds

A webhook delivery **fails** and triggers a retry when:

* Your endpoint returns a non-2xx status code (4xx, 5xx)
* Connection timeout (20 seconds exceeded)
* Connection refused or DNS failure

## Failure Handling

If all 10 delivery attempts fail:

* Webhook is marked as permanently failed
* You can view failed webhooks in the dashboard
* Failed webhooks can be manually retried

## Event Ordering

**Webhooks are not guaranteed to arrive in lifecycle order.** Two events fired close together for the same resource (e.g. `wallet.transaction.updated` for `Broadcasted` and `Success` on a fast testnet, or `transaction.auto.deposit.received` and `transaction.auto.deposit.succeeded` for an instant rail) may reach your endpoint out of order due to network races, retry timing, and the fact that the envelope `created` field is second-resolution.

This is most visible in **sandbox** because testnet broadcasts and the simulate endpoints transition states sub-second. In production, longer chain confirmations and bank-rail latencies usually space the events out enough that ordering is incidental — but the contract still does not guarantee it.

**How to handle it:**

* **Treat `status` as the source of truth, not the order of arrival.** Each event carries the full resource state; the latest event by terminal-state semantics wins.
* **Use `X-Dakota-Event-ID` for idempotency** — duplicate deliveries (retries or out-of-order replays) carry the same ID.
* **For ordered processing, derive a sequence from the resource's own state machine.** For wallet transactions: `Waiting For Signature → Broadcasted → Success | Failed`. For auto-account deposits: `received → succeeded | failed`. If you receive a "later" status before an "earlier" one, accept it; the earlier one is informational.
* **Don't strictly require monotonic timestamps** — `created` and `X-Webhook-Timestamp` are second-resolution, and two events can share the same value.

If your processing must be strictly serial per resource, queue incoming events by resource ID (e.g. `wallet_id`, `transaction_id`) and reorder by status before applying.

## Webhook Headers

Every webhook request includes these headers:

| Header                      | Description                                          |
| --------------------------- | ---------------------------------------------------- |
| `Content-Type`              | `application/json`                                   |
| `User-Agent`                | `Dakota-Webhooks/1.0`                                |
| `X-Dakota-Event-ID`         | Unique event identifier (use for idempotency)        |
| `X-Dakota-Event-Type`       | The type of event (e.g., `transaction.auto.updated`) |
| `X-Dakota-Delivery-Attempt` | Current attempt number (1-10)                        |
| `X-Webhook-Signature`       | Ed25519 signature (base64 encoded)                   |
| `X-Webhook-Timestamp`       | Unix timestamp when webhook was sent                 |

Use `X-Dakota-Event-ID` for idempotency - it remains the same across all retry attempts for the same event.

# Best Practices

## Endpoint Requirements

* **HTTPS only**: Dakota Platform only sends webhooks to HTTPS endpoints
* **Fast response**: Respond within 30 seconds
* **2xx status codes**: Return 200-299 status for successful processing
* **Idempotent**: Handle duplicate webhooks gracefully

## Security

* Always verify webhook signatures
* Use HTTPS for your webhook endpoint
* Validate event data before processing
* Log webhook events for debugging

## Processing Best Practices

Implement robust webhook event processing with error handling, retries, and idempotency:

<CodeGroup>
  ```javascript Node.js theme={null}
  // Robust webhook event processing with error handling
  class WebhookProcessor {
    constructor() {
      this.processedEvents = new Set(); // Use Redis/database in production
      this.maxRetries = 3;
    }

    async handleWebhookEvent(event) {
      const eventId = event.id;
      
      // Make processing idempotent
      if (this.isEventAlreadyProcessed(eventId)) {
        console.log('Event already processed:', eventId);
        return { success: true, message: 'Already processed' };
      }

      try {
        await this.processEventWithRetry(event);
        this.markEventAsProcessed(eventId);
        return { success: true, message: 'Event processed successfully' };
      } catch (error) {
        console.error('Failed to process event after retries:', error);
        await this.storeFailedEvent(event, error);
        throw error;
      }
    }

    async processEventWithRetry(event, attempt = 1) {
      try {
        await this.processEvent(event);
      } catch (error) {
        if (attempt < this.maxRetries) {
          const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
          console.log(`Retrying event processing in ${delay}ms (attempt ${attempt + 1})`);
          await new Promise(resolve => setTimeout(resolve, delay));
          return this.processEventWithRetry(event, attempt + 1);
        }
        throw error;
      }
    }

    async processEvent(event) {
      console.log(`Processing event: ${event.event} (${event.id})`);
      
      switch (event.event) {
        case 'transaction.auto.created':
          await this.handleTransactionCreated(event.data);
          break;
        case 'transaction.auto.updated':
          // Inspect event.data.status to distinguish processing/completed/failed
          await this.handleTransactionUpdate(event.data);
          break;
        case 'customer.kyb_status.updated':
          await this.handleCustomerKybUpdate(event.data);
          break;
        case 'customer.created':
          await this.handleCustomerCreated(event.data);
          break;
        default:
          console.log(`Unknown event type: ${event.event}`);
      }
    }

    async handleTransactionUpdate(data) {
      console.log(`Transaction ${data.id} status: ${data.previous_status} → ${data.status}`);
      await this.updateTransactionStatus(data.id, data.status);
      
      if (data.status === 'completed') {
        await this.notifyTransactionCompleted(data);
      } else if (data.status === 'failed') {
        await this.notifyTransactionFailed(data);
      }
    }

    async handleTransactionCompleted(data) {
      console.log(`Transaction completed: ${data.id}`);
      await this.updateTransactionStatus(data.id, 'completed');
      await this.notifyTransactionCompleted(data);
      await this.triggerPostCompletionActions(data);
    }

    async handleTransactionFailed(data) {
      console.log(`Transaction failed: ${data.id}`, data.error);
      await this.updateTransactionStatus(data.id, 'failed');
      await this.notifyTransactionFailed(data);
      await this.handleTransactionFailure(data);
    }

    async handleCustomerKybUpdate(data) {
      console.log(`Customer ${data.id} KYB status: ${data.previous_status} → ${data.kyb_status}`);
      await this.updateCustomerKybStatus(data.id, data.kyb_status);
      
      if (data.kyb_status === 'active') {
        await this.notifyCustomerApproved(data);
        await this.enableCustomerFeatures(data.id);
      } else if (data.kyb_status === 'rejected') {
        await this.notifyCustomerRejected(data);
        await this.handleKybRejection(data);
      }
    }

    async handleCustomerCreated(data) {
      console.log(`New customer created: ${data.id}`);
      await this.syncCustomerToDatabase(data);
      await this.sendWelcomeEmail(data);
    }

    isEventAlreadyProcessed(eventId) {
      return this.processedEvents.has(eventId);
    }

    markEventAsProcessed(eventId) {
      this.processedEvents.add(eventId);
      console.log('Marked event as processed:', eventId);
    }

    async storeFailedEvent(event, error) {
      console.log('Storing failed event for review:', event.id, error.message);
      // Store in database for manual review
    }

    // Business logic methods (implement according to your needs)
    async updateTransactionStatus(transactionId, status) { /* Your implementation */ }
    async notifyTransactionCompleted(data) { /* Your implementation */ }
    async notifyTransactionFailed(data) { /* Your implementation */ }
    async triggerPostCompletionActions(data) { /* Your implementation */ }
    async handleTransactionFailure(data) { /* Your implementation */ }
    async updateCustomerKybStatus(customerId, status) { /* Your implementation */ }
    async notifyCustomerApproved(data) { /* Your implementation */ }
    async notifyCustomerRejected(data) { /* Your implementation */ }
    async enableCustomerFeatures(customerId) { /* Your implementation */ }
    async handleKybRejection(data) { /* Your implementation */ }
    async syncCustomerToDatabase(data) { /* Your implementation */ }
    async sendWelcomeEmail(data) { /* Your implementation */ }
  }

  // Usage in Express route
  const webhookProcessor = new WebhookProcessor();

  app.post('/webhooks/dakota', async (req, res) => {
    // ... signature verification code ...
    
    try {
      const result = await webhookProcessor.handleWebhookEvent(event);
      console.log(result.message);
      res.status(200).send('OK');
    } catch (error) {
      console.error('Webhook processing failed:', error);
      res.status(200).send('Processing failed but acknowledged');
    }
  });
  ```

  ```python Python   theme={null}
  # Python implementation here - abbreviated for clarity
  ```

  ```go Go theme={null}
  # Go implementation here - abbreviated for clarity  
  ```

  ```rust Rust theme={null}
  # Rust implementation here - abbreviated for clarity
  ```

  ```java Java theme={null}
  # Java implementation here - abbreviated for clarity
  ```
</CodeGroup>

# Testing Webhooks

## Local Development

Use tools like ngrok to expose local endpoints:

```bash theme={null}
ngrok http 3000
# Use the HTTPS URL for webhook registration
```

## Webhook Testing

Test your webhook endpoint manually:

```bash theme={null}
curl -X POST https://your-app.com/webhooks/dakota \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: base64_encoded_signature" \
  -H "X-Webhook-Timestamp: $(date +%s)" \
  -d '{
    "event": "transaction.auto.updated",
    "data": {
      "id": "31TgvtxUdXi95dUN4M8X1rhSCNS",
      "status": "completed"
    }
  }'
```

# Troubleshooting

## Common Issues

**Webhooks Not Received**

* Check that your endpoint returns 2xx status
* Verify your URL is accessible from the internet
* Ensure HTTPS is properly configured
* Check firewall/proxy settings

**Signature Verification Failing**

* Ensure you're using Dakota Platform's correct Ed25519 public key
* Verify the signature calculation matches our Ed25519 implementation
* Check that the timestamp and payload haven't been modified
* Ensure you're using the correct header names (`X-Webhook-Signature`, `X-Webhook-Timestamp`)

**Duplicate Webhooks**

* Implement idempotency using the event ID
* Store processed event IDs to prevent duplicates
* Use database constraints where possible

## Monitoring

* Set up alerts for webhook failures
* Log all webhook events for debugging
* Monitor endpoint response times
* Track webhook delivery success rates

# Event Types Reference

Based on the platform's actual event definitions, here are the available webhook event types:

| Event                                | Description                                   |
| ------------------------------------ | --------------------------------------------- |
| **Customer Events**                  |                                               |
| `customer.created`                   | New customer created                          |
| `customer.updated`                   | Customer information updated                  |
| `customer.kyb_status.created`        | Customer KYB status created                   |
| `customer.kyb_status.updated`        | Customer KYB status changed                   |
| `customer.kyb_link.created`          | Customer KYB link created                     |
| `customer.kyb_link.updated`          | Customer KYB link updated                     |
| `customer.kyb_application.submitted` | Customer KYB application submitted for review |
| **Transaction Events**               |                                               |
| `transaction.auto.created`           | Auto account transaction created              |
| `transaction.auto.updated`           | Auto account transaction updated              |
| `transaction.one_off.created`        | One-off transaction created                   |
| `transaction.one_off.updated`        | One-off transaction updated                   |
| **Account Events**                   |                                               |
| `auto_account.created`               | Auto account created                          |
| `auto_account.updated`               | Auto account updated                          |
| `auto_account.deleted`               | Auto account deleted                          |
| **Wallet Events**                    |                                               |
| `wallet.created`                     | Non-custodial wallet created                  |
| `wallet.updated`                     | Wallet metadata or configuration changed      |
| `wallet.deposit`                     | On-chain deposit detected at a wallet address |
| `wallet.transaction.created`         | Wallet transaction submitted                  |
| `wallet.transaction.updated`         | Wallet transaction status or details changed  |
| `wallet.policy.created`              | Policy created and attached to a wallet       |
| `wallet.policy.updated`              | Policy rules or metadata updated              |
| `wallet.signer_group.created`        | Signer group created                          |
| `wallet.signer_group.updated`        | Signer group membership or metadata updated   |
| **Other Events**                     |                                               |
| `user.created`                       | User created                                  |
| `user.updated`                       | User updated                                  |
| `user.deleted`                       | User deleted                                  |
| `api_key.created`                    | API key created                               |
| `api_key.deleted`                    | API key deleted                               |
| `recipient.created`                  | Recipient created                             |
| `recipient.updated`                  | Recipient updated                             |
| `recipient.deleted`                  | Recipient deleted                             |
| `destination.created`                | Destination created                           |
| `destination.deleted`                | Destination deleted                           |
| `target.created`                     | Target created                                |
| `target.updated`                     | Target updated                                |
| `target.deleted`                     | Target deleted                                |
| `exception.created`                  | Exception created                             |
| `exception.cleared`                  | Exception cleared                             |

# Next Steps

After setting up webhooks:

1. **[Testing Your Integration](testing)** - Test webhook delivery and processing
2. **[Transactions](/documentation/common-flows)** - Process transactions that trigger webhook events
3. **[Customer Onboarding](customer-onboarding)** - Set up KYB status webhooks

# API Reference

For detailed endpoint documentation, see:

* [Webhooks API Reference](/api-reference/webhooks/create-a-webhook-target) - Webhook target management
* [Events API Reference](/api-reference/events/list-events) - Event types and payload formats
