Greenfield API 接入 ASP.NET Core 实战
BTCPay 的 Greenfield API,就是一个普通的 REST API ,没有什么神秘的地方。难点不在框架,而在于 鉴权方式、事件时序、幂等处理 这三件事。下面按跑起来的顺序讲。
一、两个鉴权维度
Greenfield 有两层东西必须一起带上:
| 维度 | 位置 | 说明 |
| API Key | Header Authorization: token {apiKey} | 注意是 token 不是 Bearer,这是最常见的报错来源 |
| Store ID | 路径参数 /api/v1/stores/{storeId}/invoices | 路径参数,不是 header |
另外有两个容易出问题的基础要求:
1. 必须走 HTTPS ,且 BTCPay 要求你的回调/webhook 地址也是 HTTPS
2. 请求体必须是 application/json ,Content-Type 漏掉会返回 400,而不是「格式错误」这样友好的提示
API Key 的生成路径:BTCPay 后台 → Account → API Keys → Generate,
权限要勾上 btcpay.store.cancreateinvoice 和 btcpay.store.canviewinvoices ,同时把 Key 绑定到具体的 store 上(可以绑一个,也可以勾 All stores)。
如果是给别人/多店铺做集成,建议按 store 各发一把 Key,而不是用 All stores 的总钥匙。 这既是最小权限问题,也是出事时的定位问题 。
二、配置:用 Options 模式,别把密钥写进 appsettings
// BtcPayOptions.cs
public sealed class BtcPayOptions
{
public const string SectionName = "BtcPay";
public string BaseUrl { get; set; } = ""; // https://pay.example.com
public string ApiKey { get; set; } = "";
public string StoreId { get; set; } = "";
public string WebhookSecret { get; set; } = ""; // 后面校验签名用
}
builder.Services
.AddOptions<BtcPayOptions>()
.Bind(builder.Configuration.GetSection(BtcPayOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
密钥放 User Secrets(本地)或环境变量/Key Vault(生产)。BaseUrl 结尾 不要带 / ,否则拼 URL 时会出现 //api/v1,有的反代直接 404。
三、HttpClient:用 IHttpClientFactory + 一个 DelegatingHandler
public sealed class BtcPayAuthHandler : DelegatingHandler
{
private readonly IOptionsMonitor<BtcPayOptions> _options;
public BtcPayAuthHandler(IOptionsMonitor<BtcPayOptions> options) => _options = options;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var apiKey = _options.CurrentValue.ApiKey;
request.Headers.TryAddWithoutValidation("Authorization", $"token {apiKey}");
return base.SendAsync(request, ct);
}
}
builder.Services
.AddTransient<BtcPayAuthHandler>()
.AddHttpClient<BtcPayClient>((sp, http) =>
{
var opt = sp.GetRequiredService<IOptions<BtcPayOptions>>().Value;
http.BaseAddress = new Uri(opt.BaseUrl.TrimEnd('/') + "/");
http.Timeout = TimeSpan.FromSeconds(30);
http.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
})
.AddHttpMessageHandler<BtcPayAuthHandler>()
.AddStandardResilienceHandler(); // 见第七节,注意这里的坑
为什么用 typed client :StoreId、反序列化、错误映射都能收进一个类里,业务代码就不用再关心 URL 拼装了。
四、调用接口:创建 Invoice
DTO 只保留会用到的字段,不要照抄全部 schema:
public sealed record CreateInvoiceRequest(
decimal Amount,
string Currency,
string? OrderId = null,
string? ItemDesc = null,
string? RedirectUrl = null,
CheckoutOptions? Checkout = null);
public sealed record CheckoutOptions(
string? DefaultLanguage = null,
string? RedirectURL = null,
bool? RedirectAutomatically = null);
public sealed record CreateInvoiceResponse(
string Id,
string Status,
CheckoutLinkData CheckoutLink);
public sealed record CheckoutLinkData(string? RedirectUrl, string? Url);
public sealed class BtcPayClient(HttpClient http, IOptions<BtcPayOptions> options)
{
public async Task<CreateInvoiceResponse> CreateInvoiceAsync(
CreateInvoiceRequest req, CancellationToken ct = default)
{
var storeId = options.Value.StoreId;
var resp = await http.PostAsJsonAsync(
$"api/v1/stores/{storeId}/invoices", req, ct);
await EnsureSuccessAsync(resp, ct);
return await resp.Content.ReadFromJsonAsync<CreateInvoiceResponse>(ct)
?? throw new InvalidOperationException("BTCPay 返回空响应");
}
private static async Task EnsureSuccessAsync(
HttpResponseMessage resp, CancellationToken ct)
{
if (resp.IsSuccessStatusCode) return;
var body = await resp.Content.ReadAsStringAsync(ct);
throw new BtcPayException(
(int)resp.StatusCode,
string.IsNullOrWhiteSpace(body) ? resp.ReasonPhrase ?? "未知错误" : body);
}
}
public sealed class BtcPayException(int statusCode, string responseBody)
: Exception($"BTCPay API 调用失败 ({statusCode}): {responseBody}")
{
public int StatusCode { get; } = statusCode;
public string ResponseBody { get; } = responseBody;
}
这里有个实践要点 :不要只抛 EnsureSuccessStatusCode()。BTCPay 的错误响应体里带 code 和
message 字段,401(Key 权限不足)、403(Key 未绑该 store)、400(金额/币种非法)在日志里不区分开,线上排查会浪费很多时间。最好把错误体原样包进异常再记录。
五、业务闭环:订单状态机是核心
这是整个接入里 唯一真正难的地方
。事件时序:
| 支付方式 | InvoiceProcessing | InvoiceSettled | 间隔 |
| 链上 | 进入 mempool 时触发 | 达到确认数后触发 | 通常 10 分钟以上 |
| 闪电网络 | 支付完成时触发 | 几乎同时触发 | 秒级 |
所以订单必须能表示「 已收到但未确认
」这个中间态。状态机建议:
Pending ──创建invoice──> AwaitingPayment
AwaitingPayment ──Processing──> Paid(未确认)
Paid ──Settled──> Completed
Paid/AwaitingPayment ──Expired/Invalid──> Failed
对应的实体字段至少要有一个 BtcPayInvoiceId 且加唯一索引 ——webhook 会重复投递,靠唯一索引做幂等是最省事的方式:
public class PaymentOrder
{
public Guid Id { get; set; }
public string OrderNo { get; set; } = "";
public decimal Amount { get; set; }
public string Currency { get; set; } = "";
public string? BtcPayInvoiceId { get; set; } // 唯一索引
public string Status { get; set; } = "Pending";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? SettledAt { get; set; }
}
创建发票时给 orderId 赋自己的 OrderNo,webhook 回来时用它反查本地订单——比用 invoiceId 反查更可靠,因为 invoiceId 有可能在写库之前就丢了(比如创建后进程挂了)。
六、Webhook:签名校验 + 幂等
6.1 签名校验(必做)
Greenfield webhook 用 HMAC-SHA256 ,签名放在 BTCPay-Sig header,格式是 sha256=xxxx,对
原始请求体 计算。
public static bool VerifySignature(string body, string? headerValue, string secret)
{
if (string.IsNullOrEmpty(headerValue) || !headerValue.StartsWith("sha256="))
return false;
var provided = headerValue["sha256=".Length..].Trim();
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var computed =
Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)))
.ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided),
Encoding.UTF8.GetBytes(computed));
}
两个坑,都是真实会踩到的:
3. 必须读原始 body ,不能先绑定成 model 再序列化回来算哈希——Razor/MVC 的 model binder 会改写字段顺序和空白,签名必然对不上。所以 webhook 端点要用 [FromBody] string 或者手动读 Request.Body。
4. 比较必须用 FixedTimeEquals,不要用 ==。虽然 webhook 场景下时序攻击不现实,但这是习惯问题,代码评审时也会被问。
6.2 幂等 + 快速响应
BTCPay 的回调
会重试 ,且在特定情况(比如「收到支付但又开了新的发票」)会多次触发同一事件。所以:
● 先快速返回 200 ,业务处理丢进 Channel<WebhookJob> 后台队列
● 处理时「先查状态,状态已到位就跳过」
● 定时回查兜底(webhook 丢失时不会有任何通知,这个必须有)
[ApiController]
public class BtcPayWebhookController : ControllerBase
{
[HttpPost("/webhooks/btcpay")]
public async Task<IActionResult> Receive(
[FromServices] IOptions<BtcPayOptions> opt,
[FromServices] Channel<WebhookJob> queue)
{
// 读原始体
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var sig = Request.Headers["BTCPay-Sig"].FirstOrDefault();
if (!WebhookSignature.VerifySignature(body, sig, opt.Value.WebhookSecret))
return Unauthorized();
var pending = JsonSerializer.Deserialize<PendingWebhook>(body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (pending is null) return BadRequest();
// 立即入队,不在这里做业务
await queue.Writer.WriteAsync(new WebhookJob(pending));
return Ok();
}
}
public sealed record PendingWebhook(string DeliveryId, string WebhookId, string Type, JsonElement Data);
public sealed record WebhookJob(PendingWebhook Payload);
注册方式:后台 → Store → Webhooks → Create,URL 填 https://你的域名/webhooks/btcpay, 勾选事件里一定要包含 :
● InvoiceProcessing
● InvoiceSettled
● InvoiceExpired
● InvoiceInvalid
Secret 复制到
BtcPayOptions.WebhookSecret。
6.3 兜底轮询
public async Task<InvoiceStatus?> GetInvoiceAsync(string invoiceId, CancellationToken ct)
{
var resp = await http.GetAsync($"api/v1/stores/{options.Value.StoreId}/invoices/{invoiceId}", ct);
if (resp.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccessAsync(resp, ct);
return await resp.Content.ReadFromJsonAsync<InvoiceStatus>(ct);
}
建议用一个 BackgroundService 扫「处于 Paid(AwaitingPayment) 且创建超过 N 分钟」的订单,逐个查一次状态。 BTCPay 官方明确说过 webhook 不保证送达 ,所以这一步不是可选项。
七、重试策略:AddStandardResilienceHandler 的坑
上面写了 .AddStandardResilienceHandler(),但这里要提醒一句:
创建 Invoice 这类非幂等的 POST 请求,默认会被重试。 如果第一次请求其实已经成功、只是响应超时了,重试会创建出
两张发票 。用户看到哪张、你库里存哪张,就乱了。
三种处理方式,按推荐顺序:
5. 用 orderId 做幂等并查重 :创建前先按 orderId 查一次是否已有 invoice(推荐,Greenfield 支持按 orderId 查)
6. 给 POST 请求单独配置 AddResilienceHandler,只允许重试
HttpRequestException 且限定 1 次
7. 最省事但最差:POST 不加重试,只加重试到 GET
// 方案 1 的关键调用:按 orderId 查已有 invoice
public async Task<CreateInvoiceResponse?> FindByOrderIdAsync(
string orderId, CancellationToken ct = default)
{
var resp = await http.GetAsync(
$"api/v1/stores/{options.Value.StoreId}/invoices?orderId={Uri.EscapeDataString(orderId)}", ct);
await EnsureSuccessAsync(resp, ct);
var list = await resp.Content.ReadFromJsonAsync<List<CreateInvoiceResponse>>(ct);
return list?.FirstOrDefault();
}
另外还有个细节:HttpClient.Timeout = 30s,但 BTCPay 在某些负载下创建发票会超过 10 秒, 加上重试放大,一次结账可能占掉 60 秒 。电商场景里用户等不了这么久,所以更稳的做法是: 创建或获取发票这一步做成异步任务
,前端轮询自己后端的订单状态。
八、DI 注册总览
// Program.cs 里按顺序拼起来
builder.Services.Configure<BtcPayOptions>(
builder.Configuration.GetSection(BtcPayOptions.SectionName));
builder.Services
.AddTransient<BtcPayAuthHandler>()
.AddHttpClient<BtcPayClient>((sp, http) => { /* 见第三节 */ })
.AddHttpMessageHandler<BtcPayAuthHandler>();
builder.Services.AddSingleton(Channel.CreateUnbounded<WebhookJob>());
builder.Services.AddHostedService<WebhookDispatcher>(); // 消费队列
builder.Services.AddHostedService<InvoiceReconciler>(); // 兜底轮询
Channel<WebhookJob> 注册成单例,webhook Controller 拿 writer,HostedService 拿 reader。 注意别用 Channel.CreateBounded 又不设满时的处理策略 ,webhook 积压时会阻塞 HTTP 请求。
九、开发调试:先 Regtest 再说
不要一开始就对着 mainnet 调。标准做法:
git clone https://github.com/btcpayserver/btcpayserver.git
cd btcpayserver/BTCPayServer.Tests
docker-compose -f docker-compose.yml up dev
起完后本地是 regtest 环境,页面顶部显示 REGTEST 标识,可以用 Regtest 钱包给自己发币, 几秒钟出块,不花真钱 。这一步在国内能省掉大量等待时间——不需要 mainnet 的 IBD,几秒钟就能开始测。
调试时把 SaaS dev tunnel(或者自建内网穿透)指向本地的 /webhooks/btcpay,才能收到 webhook。
十、上线前检查清单
● [ ] API Key 按 store 下发,权限只勾 cancreateinvoice + canviewinvoices
● [ ] API Key / WebhookSecret 走环境变量或 Key Vault,仓库里零硬编码
● [ ] BtcPayInvoiceId 和 OrderNo 都有唯一索引
● [ ] webhook 端点读原始 body 算 HMAC,用 FixedTimeEquals 比较
● [ ] webhook 端点不做重业务,立即返回 200
● [ ] 事件覆盖 Processing / Settled / Expired / Invalid 四类
● [ ] 有兜底轮询任务
● [ ] 创建 Invoice 的 POST 不会因重试产生重复发票
● [ ] 日志里记录 invoiceId、orderId、事件类型、签名校验结果(但不记 body 全文和 secret)
● [ ] 订单状态机能表达「已付未确认」