{
  "eventSchema": {
    "$defs": {
      "guid": {
        "format": "uuid",
        "type": "string"
      }
    },
    "$id": "https://billixi.local/schemas/webhook-event-v1",
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "allOf": [
      {
        "if": {
          "properties": {
            "type": {
              "pattern": "^product\\."
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "productId": {
                  "$ref": "#/$defs/guid"
                },
                "status": {
                  "type": "string"
                }
              },
              "required": [
                "productId",
                "status"
              ]
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "type": {
              "const": "payment.completed"
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "amount": {
                  "type": "number"
                },
                "currency": {
                  "type": "string"
                },
                "paymentIntentId": {
                  "$ref": "#/$defs/guid"
                },
                "productId": {
                  "$ref": "#/$defs/guid"
                }
              },
              "required": [
                "paymentIntentId",
                "productId",
                "productName",
                "amount",
                "currency",
                "network",
                "saleType",
                "platformFee",
                "affiliateFee",
                "sellerAmount"
              ]
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "type": {
              "const": "payment.failed"
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "amount": {
                  "type": "number"
                },
                "paymentIntentId": {
                  "$ref": "#/$defs/guid"
                },
                "productId": {
                  "$ref": "#/$defs/guid"
                },
                "reason": {
                  "type": "string"
                }
              },
              "required": [
                "paymentIntentId",
                "productId",
                "amount",
                "currency",
                "reason"
              ]
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "type": {
              "const": "payment.refunded"
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "paymentIntentId": {
                  "$ref": "#/$defs/guid"
                },
                "productId": {
                  "$ref": "#/$defs/guid"
                },
                "refundAmount": {
                  "type": "number"
                }
              },
              "required": [
                "paymentIntentId",
                "productId",
                "amount",
                "currency",
                "refundAmount"
              ]
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "type": {
              "const": "affiliate.sale"
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "paymentIntentId": {
                  "$ref": "#/$defs/guid"
                },
                "resourceId": {
                  "$ref": "#/$defs/guid"
                }
              },
              "required": [
                "paymentIntentId",
                "resourceId",
                "status"
              ]
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "type": {
              "const": "affiliate.link_created"
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "parentId": {
                  "type": "string"
                },
                "resourceId": {
                  "type": "string"
                },
                "workspaceId": {
                  "$ref": "#/$defs/guid"
                }
              },
              "required": [
                "workspaceId",
                "resourceId",
                "parentId",
                "status"
              ]
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "type": {
              "const": "delivery.ready"
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "parentId": {
                  "type": "string"
                },
                "resourceId": {
                  "type": "string"
                },
                "status": {
                  "const": "ready"
                }
              },
              "required": [
                "resourceId",
                "parentId",
                "status"
              ]
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "type": {
              "pattern": "^support\\.ticket\\."
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "status": {
                  "type": "string"
                },
                "ticketId": {
                  "$ref": "#/$defs/guid"
                }
              },
              "required": [
                "ticketId",
                "status"
              ]
            }
          }
        }
      },
      {
        "if": {
          "properties": {
            "type": {
              "const": "support.ticket.replied"
            }
          }
        },
        "then": {
          "properties": {
            "data": {
              "properties": {
                "commentId": {
                  "$ref": "#/$defs/guid"
                }
              },
              "required": [
                "commentId"
              ]
            }
          }
        }
      }
    ],
    "properties": {
      "createdAt": {
        "format": "date-time",
        "type": "string"
      },
      "data": {
        "type": "object"
      },
      "id": {
        "pattern": "^evt_[0-9a-f]{32}$",
        "type": "string"
      },
      "livemode": {
        "type": "boolean"
      },
      "schemaVersion": {
        "const": "1"
      },
      "test": {
        "type": "boolean"
      },
      "type": {
        "enum": [
          "product.created",
          "product.updated",
          "product.published",
          "payment.completed",
          "payment.failed",
          "payment.refunded",
          "affiliate.link_created",
          "affiliate.sale",
          "support.ticket.created",
          "support.ticket.replied",
          "support.ticket.status_changed",
          "delivery.ready"
        ]
      }
    },
    "required": [
      "id",
      "type",
      "schemaVersion",
      "createdAt",
      "livemode",
      "test",
      "data"
    ],
    "title": "Billixi personal webhook event v1",
    "type": "object"
  },
  "schemaVersion": 1,
  "snippets": {
    "csharp": "using System.Security.Cryptography;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\n\nstatic string Required(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : throw new Exception($\"Set {name}\");\nusing var stream = new MemoryStream();\nawait Console.OpenStandardInput().CopyToAsync(stream);\nbyte[] raw = stream.ToArray();\nvar fields = Required(\"BILLIXI_SIGNATURE_V2\").Split(',');\nif (fields.Length != 2 || !Regex.IsMatch(fields[0], @\"\\At=[0-9]+\\z\") || !Regex.IsMatch(fields[1], @\"\\Av2=[0-9a-f]{64}\\z\")) throw new Exception(\"Malformed V2 signature\");\nlong timestamp = long.Parse(fields[0][2..]);\nlong now = long.Parse(Required(\"BILLIXI_NOW\"));\nif (Math.Abs(now - timestamp) > 300) throw new Exception(\"Stale timestamp\");\nstring secret = Required(\"BILLIXI_WEBHOOK_SECRET\");\nif (!Regex.IsMatch(secret, @\"\\Awhsec_[A-Za-z0-9_-]+\\z\")) throw new Exception(\"Invalid secret format\");\nstring encoded = secret[6..].Replace('-', '+').Replace('_', '/');\nbyte[] key = Convert.FromBase64String(encoded.PadRight((encoded.Length + 3) / 4 * 4, '='));\nbyte[] actual = Convert.FromHexString(fields[1][3..]);\nbyte[] prefix = Encoding.ASCII.GetBytes($\"{timestamp}.\");\nbyte[] signed = new byte[prefix.Length + raw.Length];\nprefix.CopyTo(signed, 0); raw.CopyTo(signed, prefix.Length);\nbyte[] expected = HMACSHA256.HashData(key, signed);\nif (actual.Length != expected.Length || !CryptographicOperations.FixedTimeEquals(actual, expected)) throw new Exception(\"Invalid signature\");\nusing var json = JsonDocument.Parse(raw);\nstring? id = json.RootElement.GetProperty(\"id\").GetString();\nif (id != Required(\"BILLIXI_EVENT_ID\")) throw new Exception(\"Event ID mismatch\");\nif ((Environment.GetEnvironmentVariable(\"BILLIXI_SEEN_EVENT_IDS\") ?? \"\").Split(',').Contains(id)) throw new Exception(\"Duplicate event\");\n// Persist event ID in a unique-key inbox before applying business effects.\nConsole.WriteLine(id);\n",
    "go": "package main\nimport (\n \"crypto/hmac\"\n \"crypto/sha256\"\n \"encoding/base64\"\n \"encoding/hex\"\n \"encoding/json\"\n \"fmt\"\n \"io\"\n \"os\"\n \"regexp\"\n \"strconv\"\n \"strings\"\n)\nfunc required(name string) string { v := os.Getenv(name); if v == \"\" { panic(\"Set \" + name) }; return v }\nfunc main() {\n raw, err := io.ReadAll(os.Stdin); if err != nil { panic(err) }\n fields := strings.Split(required(\"BILLIXI_SIGNATURE_V2\"), \",\")\n if len(fields) != 2 || !regexp.MustCompile(`^t=[0-9]+$`).MatchString(fields[0]) || !regexp.MustCompile(`^v2=[0-9a-f]{64}$`).MatchString(fields[1]) { panic(\"Malformed V2 signature\") }\n timestamp, err := strconv.ParseInt(fields[0][2:], 10, 64); if err != nil { panic(err) }\n now, err := strconv.ParseInt(required(\"BILLIXI_NOW\"), 10, 64); if err != nil { panic(err) }\n if now-timestamp > 300 || timestamp-now > 300 { panic(\"Stale timestamp\") }\n secret := required(\"BILLIXI_WEBHOOK_SECRET\")\n if !regexp.MustCompile(`^whsec_[A-Za-z0-9_-]+$`).MatchString(secret) { panic(\"Invalid secret format\") }\n key, err := base64.RawURLEncoding.DecodeString(secret[6:]); if err != nil { panic(err) }\n actual, err := hex.DecodeString(fields[1][3:]); if err != nil { panic(err) }\n mac := hmac.New(sha256.New, key); mac.Write([]byte(fields[0][2:] + \".\")); mac.Write(raw)\n if !hmac.Equal(actual, mac.Sum(nil)) { panic(\"Invalid signature\") }\n var event struct { ID string `json:\"id\"` }\n if err := json.Unmarshal(raw, &event); err != nil { panic(err) }\n if event.ID != required(\"BILLIXI_EVENT_ID\") { panic(\"Event ID mismatch\") }\n for _, seen := range strings.Split(os.Getenv(\"BILLIXI_SEEN_EVENT_IDS\"), \",\") { if seen == event.ID { panic(\"Duplicate event\") } }\n // Persist event ID in a unique-key inbox before applying business effects.\n fmt.Println(event.ID)\n}\n",
    "javascript": "import { createHmac, timingSafeEqual } from 'node:crypto'\n\nconst required = name => {\n  const value = process.env[name]\n  if (!value) throw new Error(`Set ${name}`)\n  return value\n}\nconst raw = await new Promise((resolve, reject) => {\n  const chunks = []\n  process.stdin.on('data', chunk => chunks.push(chunk))\n  process.stdin.on('end', () => resolve(Buffer.concat(chunks)))\n  process.stdin.on('error', reject)\n})\nconst header = required('BILLIXI_SIGNATURE_V2')\nconst fields = header.split(',')\nif (fields.length !== 2 || !/^t=[0-9]+$/.test(fields[0]) || !/^v2=[0-9a-f]{64}$/.test(fields[1])) throw new Error('Malformed V2 signature')\nconst timestamp = Number(fields[0].slice(2))\nconst now = Number(required('BILLIXI_NOW'))\nif (!Number.isSafeInteger(timestamp) || !Number.isSafeInteger(now) || Math.abs(now - timestamp) > 300) throw new Error('Stale timestamp')\nconst secret = required('BILLIXI_WEBHOOK_SECRET')\nif (!/^whsec_[A-Za-z0-9_-]+$/.test(secret)) throw new Error('Invalid secret format')\nconst key = Buffer.from(secret.slice(6), 'base64url')\nconst actual = Buffer.from(fields[1].slice(3), 'hex')\nconst expected = createHmac('sha256', key).update(Buffer.from(`${timestamp}.`, 'ascii')).update(raw).digest()\nif (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new Error('Invalid signature')\nconst event = JSON.parse(raw.toString('utf8'))\nif (event.id !== required('BILLIXI_EVENT_ID')) throw new Error('Event ID mismatch')\nif ((process.env.BILLIXI_SEEN_EVENT_IDS || '').split(',').includes(event.id)) throw new Error('Duplicate event')\n// Persist event.id in a unique-key inbox before applying business effects.\nconsole.log(event.id)\n",
    "php": "<?php\nfunction required(string $name): string {\n    $value = getenv($name);\n    if ($value === false || $value === '') throw new RuntimeException(\"Set $name\");\n    return $value;\n}\n$raw = file_get_contents('php://stdin');\n$fields = explode(',', required('BILLIXI_SIGNATURE_V2'));\nif (count($fields) !== 2 || !preg_match('/^t=[0-9]+$/D', $fields[0]) || !preg_match('/^v2=[0-9a-f]{64}$/D', $fields[1])) throw new RuntimeException('Malformed V2 signature');\n$timestamp = (int)substr($fields[0], 2);\nif (abs((int)required('BILLIXI_NOW') - $timestamp) > 300) throw new RuntimeException('Stale timestamp');\n$secret = required('BILLIXI_WEBHOOK_SECRET');\nif (!preg_match('/^whsec_[A-Za-z0-9_-]+$/D', $secret)) throw new RuntimeException('Invalid secret format');\n$key = base64_decode(strtr(substr($secret, 6), '-_', '+/'), true);\nif ($key === false) throw new RuntimeException('Invalid secret encoding');\n$expected = hash_hmac('sha256', (string)$timestamp . '.' . $raw, $key, true);\n$actual = hex2bin(substr($fields[1], 3));\nif ($actual === false || !hash_equals($expected, $actual)) throw new RuntimeException('Invalid signature');\n$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);\nif (($event['id'] ?? null) !== required('BILLIXI_EVENT_ID')) throw new RuntimeException('Event ID mismatch');\nif (in_array($event['id'], explode(',', getenv('BILLIXI_SEEN_EVENT_IDS') ?: ''), true)) throw new RuntimeException('Duplicate event');\n// Persist event ID in a unique-key inbox before applying business effects.\necho $event['id'], PHP_EOL;\n",
    "python": "import base64\nimport hashlib\nimport hmac\nimport json\nimport os\nimport re\nimport sys\n\ndef required(name):\n    value = os.environ.get(name)\n    if not value:\n        raise ValueError(f'Set {name}')\n    return value\n\nraw = sys.stdin.buffer.read()\nfields = required('BILLIXI_SIGNATURE_V2').split(',')\nif len(fields) != 2 or not re.fullmatch(r't=[0-9]+', fields[0]) or not re.fullmatch(r'v2=[0-9a-f]{64}', fields[1]):\n    raise ValueError('Malformed V2 signature')\ntimestamp = int(fields[0][2:])\nif abs(int(required('BILLIXI_NOW')) - timestamp) > 300:\n    raise ValueError('Stale timestamp')\nsecret = required('BILLIXI_WEBHOOK_SECRET')\nif not re.fullmatch(r'whsec_[A-Za-z0-9_-]+', secret):\n    raise ValueError('Invalid secret format')\nencoded = secret[6:]\nkey = base64.urlsafe_b64decode(encoded + '=' * (-len(encoded) % 4))\nactual = bytes.fromhex(fields[1][3:])\nexpected = hmac.new(key, str(timestamp).encode('ascii') + b'.' + raw, hashlib.sha256).digest()\nif not hmac.compare_digest(actual, expected):\n    raise ValueError('Invalid signature')\nevent = json.loads(raw)\nif event.get('id') != required('BILLIXI_EVENT_ID'):\n    raise ValueError('Event ID mismatch')\nif event['id'] in os.environ.get('BILLIXI_SEEN_EVENT_IDS', '').split(','):\n    raise ValueError('Duplicate event')\n# Persist event ID in a unique-key inbox before applying business effects.\nprint(event['id'])\n",
    "typescript": "import { createHmac, timingSafeEqual } from 'node:crypto'\n\nfunction required(name: string): string {\n  const value = process.env[name]\n  if (!value) throw new Error(`Set ${name}`)\n  return value\n}\nconst chunks: Buffer[] = []\nfor await (const chunk of process.stdin) chunks.push(Buffer.from(chunk))\nconst raw = Buffer.concat(chunks)\nconst fields = required('BILLIXI_SIGNATURE_V2').split(',')\nif (fields.length !== 2 || !/^t=[0-9]+$/.test(fields[0]!) || !/^v2=[0-9a-f]{64}$/.test(fields[1]!)) throw new Error('Malformed V2 signature')\nconst timestamp = Number(fields[0]!.slice(2))\nconst now = Number(required('BILLIXI_NOW'))\nif (!Number.isSafeInteger(timestamp) || !Number.isSafeInteger(now) || Math.abs(now - timestamp) > 300) throw new Error('Stale timestamp')\nconst secret = required('BILLIXI_WEBHOOK_SECRET')\nif (!/^whsec_[A-Za-z0-9_-]+$/.test(secret)) throw new Error('Invalid secret format')\nconst key = Buffer.from(secret.slice(6), 'base64url')\nconst actual = Buffer.from(fields[1]!.slice(3), 'hex')\nconst expected = createHmac('sha256', key).update(Buffer.from(`${timestamp}.`, 'ascii')).update(raw).digest()\nif (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new Error('Invalid signature')\nconst event = JSON.parse(raw.toString('utf8')) as { id?: string }\nif (event.id !== required('BILLIXI_EVENT_ID')) throw new Error('Event ID mismatch')\nif ((process.env.BILLIXI_SEEN_EVENT_IDS || '').split(',').includes(event.id)) throw new Error('Duplicate event')\n// Persist event.id in a unique-key inbox before applying business effects.\nconsole.log(event.id)\n"
  }
}
