Compare commits

...

15 Commits

Author SHA1 Message Date
7ff095b79e fix swagger test 2020-11-02 15:55:39 +01:00
64ed15f18f wrong label on webhook result log 2020-11-02 08:58:38 +01:00
ca63c54dd5 strip out newlines too 2020-11-02 08:40:14 +01:00
a930ba1092 update sawagger doc 2020-11-02 08:25:57 +01:00
830993b5fe Change event signing to show pubkey in hex, use simpler sha256 hash instead of bitcoin core signing, improve test to not need a HomeController junk action 2020-11-02 08:19:39 +01:00
fd23fcb639 Add to swagger 2020-11-01 13:01:14 +01:00
e8fdd83fc1 fixes and tests 2020-11-01 11:01:59 +01:00
18326350a9 Add webhooks to store api 2020-10-31 11:20:37 +01:00
7bc19a2d41 consisten styling of button 2020-10-31 11:16:22 +01:00
e5a2981f0e add webhooks to details UI 2020-10-31 11:11:11 +01:00
ee5fac2ccc finishing touches 2020-10-31 09:54:11 +01:00
fcb22de3e9 add ui to adding to store level 2020-10-30 18:10:47 +01:00
584cee0fcd reduce complicated code induced by drugs 2020-10-30 17:24:06 +01:00
5be640b091 wip 2020-10-30 16:48:33 +01:00
48edbe145e WIP: GreenField Webhooks
Introduces a more versatile webhooks system. Can be created on a store level, and can have multiple webhooks per event. Webhooks can also be created non-specific to invoices. There will also be a way to verify the webhook using a private key saved in the store( for store level webhooks) that utilizes the same message signing/verify  system from Bitcoin Core.

Events can be subscribed at different branchings, for example a webhook registered for "invoice" will subscribe you to all invoice events.

Also included is support to call webhooks to Tor onion urls.

Have also separated BItpay IPNs from the Invoice event logger.
2020-10-30 11:32:44 +01:00
42 changed files with 870 additions and 101 deletions

View File

@ -0,0 +1,58 @@
using System.Text;
using NBitcoin;
using NBitcoin.Crypto;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Events
{
public class GreenFieldEvent<T> : IGreenFieldEvent
{
public string EventType { get; set; }
[JsonIgnore]
public object Payload
{
get
{
return PayloadParsed;
}
set
{
{
PayloadParsed = (T)value;
}
}
}
[JsonProperty("payload")] public T PayloadParsed { get; set; }
public string Signature { get; set; }
public void SetSignature(string url, Key key)
{
uint256 hash = new uint256(Hashes.SHA256(Encoding.UTF8.GetBytes(GetMessage(url))));
Signature = Encoders.Hex.EncodeData(key.Sign(hash).ToDER());
}
public bool VerifySignature(string url, PubKey key)
{
uint256 hash = new uint256(Hashes.SHA256(Encoding.UTF8.GetBytes(GetMessage(url))));
return key.Verify(hash, new ECDSASignature(Encoders.Hex.DecodeData(Signature)));
}
protected virtual string GetMessage(string url )
{
return Normalize($"{Normalize(url)}_{JsonConvert.SerializeObject(Payload)}");
}
private string Normalize(string str)
{
return str
.Replace(" ", "")
.Replace("\t", "")
.Replace("\n", "")
.Replace("\r", "")
.ToLowerInvariant();
}
}
}

View File

@ -0,0 +1,13 @@
using NBitcoin;
namespace BTCPayServer.Client.Events
{
public interface IGreenFieldEvent
{
string EventType { get; set; }
object Payload { get; set; }
string Signature { get; set; }
void SetSignature(string url, Key key);
bool VerifySignature(string url, PubKey key);
}
}

View File

@ -0,0 +1,16 @@
using BTCPayServer.Client.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Events
{
public class InvoiceStatusChangeEventPayload
{
public const string EventType = "invoice_status";
public string InvoiceId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public InvoiceStatus Status { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public InvoiceExceptionStatus AdditionalStatus { get; set; }
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
@ -14,6 +15,7 @@ namespace BTCPayServer.Client.Models
public string Currency { get; set; }
public JObject Metadata { get; set; }
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
public List<WebhookSubscription> Webhooks { get; set; }
public class CheckoutOptions
{

View File

@ -55,6 +55,7 @@ namespace BTCPayServer.Client.Models
public bool PayJoinEnabled { get; set; }
public bool LightningPrivateRouteHints { get; set; }
public List<WebhookSubscription> Webhooks { get; set; }
[JsonExtensionData]

View File

@ -6,5 +6,7 @@ namespace BTCPayServer.Client.Models
/// the id of the store
/// </summary>
public string Id { get; set; }
public string EventKey { get; set; }
}
}

View File

@ -0,0 +1,15 @@
using System;
namespace BTCPayServer.Client.Models
{
public class WebhookSubscription
{
public string EventType { get; set; }
public Uri Url { get; set; }
public override string ToString()
{
return $"{Url}_{EventType}";
}
}
}

View File

@ -16,7 +16,7 @@ namespace BTCPayServer.Tests
{
readonly IWebHost _Host = null;
readonly CancellationTokenSource _Closed = new CancellationTokenSource();
readonly Channel<JObject> _Requests = Channel.CreateUnbounded<JObject>();
readonly Channel<(JObject, Uri)> _Requests = Channel.CreateUnbounded<(JObject, Uri)>();
public CustomServer()
{
var port = Utils.FreeTcpPort();
@ -25,7 +25,9 @@ namespace BTCPayServer.Tests
{
app.Run(async req =>
{
await _Requests.Writer.WriteAsync(JsonConvert.DeserializeObject<JObject>(await new StreamReader(req.Request.Body).ReadToEndAsync()), _Closed.Token);
await _Requests.Writer.WriteAsync(
(JsonConvert.DeserializeObject<JObject>(await new StreamReader(req.Request.Body).ReadToEndAsync()),
new Uri(req.Request.GetCurrentUrl())), _Closed.Token);
req.Response.StatusCode = 200;
});
})
@ -40,18 +42,18 @@ namespace BTCPayServer.Tests
return new Uri(_Host.ServerFeatures.Get<IServerAddressesFeature>().Addresses.First());
}
public async Task<JObject> GetNextRequest()
public async Task<(JObject,Uri)> GetNextRequest()
{
using (CancellationTokenSource cancellation = new CancellationTokenSource(2000000))
{
try
{
JObject req = null;
(JObject, Uri) req = (null, null);
while (!await _Requests.Reader.WaitToReadAsync(cancellation.Token) ||
!_Requests.Reader.TryRead(out req))
{
}
return req;
}
catch (TaskCanceledException)

View File

@ -1,13 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Client.Events;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.JsonConverters;
using BTCPayServer.Lightning;
using BTCPayServer.Services;
@ -815,6 +818,7 @@ namespace BTCPayServer.Tests
[Trait("Integration", "Integration")]
public async Task InvoiceTests()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
@ -877,7 +881,68 @@ namespace BTCPayServer.Tests
//unarchive
await client.UnarchiveInvoice(user.StoreId, invoice.Id);
Assert.NotNull(await client.GetInvoice(user.StoreId, invoice.Id));
using var customServer = new CustomServer();
//let's test invoice webhooks
var store = await client.GetStore(user.StoreId);
//we can have webhooks on a store level
Assert.Empty(store.Webhooks);
store.Webhooks.Add(new WebhookSubscription()
{
Url = new Uri(customServer.GetUri(), "?src=store"),
EventType = InvoiceStatusChangeEventPayload.EventType
});
var updateStore = JsonConvert.DeserializeObject<UpdateStoreRequest>(JsonConvert.SerializeObject(store));
store = await client.UpdateStore(store.Id, updateStore);
Assert.Single(store.Webhooks);
//we can verify that messages are authentic by using a dedicated key saved in the store
var key = new PubKey(store.EventKey);
//status change
var webhookedInvoice = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest()
{
Amount = 1,
Currency = "USD",
Webhooks = new List<WebhookSubscription>()
{
new WebhookSubscription()
{
Url = new Uri(customServer.GetUri(), "?src=invoice"),
EventType = InvoiceStatusChangeEventPayload.EventType
}
}
});
Assert.Single(webhookedInvoice.Webhooks);
var totalEvents = 0;
var invResult = false;
var storeResult = false;
while (totalEvents != 2)
{
var webHooKResult = await customServer.GetNextRequest();
if (webHooKResult.Item2.ParseQueryString().Get("src") == "invoice")
{
invResult = true;
}
if (webHooKResult.Item2.ParseQueryString().Get("src") == "store")
{
storeResult = true;
}
Assert.Equal(InvoiceStatusChangeEventPayload.EventType, webHooKResult.Item1["eventType"].Value<string>());
var evt = webHooKResult.Item1.ToObject<GreenFieldEvent<InvoiceStatusChangeEventPayload>>();
Assert.True(evt.VerifySignature(webHooKResult.Item2.ToString(), key));
Assert.Equal(InvoiceStatus.New, evt.PayloadParsed.Status);
Assert.Equal(InvoiceExceptionStatus.None, evt.PayloadParsed.AdditionalStatus);
totalEvents++;
}
Assert.True(invResult);
Assert.True(storeResult);
}
}

View File

@ -92,6 +92,10 @@ namespace BTCPayServer.Tests
{
cts.Token.WaitHandle.WaitOne(500);
}
catch (EqualException) when (!cts.Token.IsCancellationRequested)
{
cts.Token.WaitHandle.WaitOne(500);
}
}
}

View File

@ -221,7 +221,7 @@ namespace BTCPayServer.Tests
bool valid = swagger.IsValid(schema, out errors);
//the schema is not fully compliant to the spec. We ARE allowed to have multiple security schemas.
var matchedError = errors.Where(error =>
error.Path == "components.securitySchemes.Basic" && error.ErrorType == ErrorType.OneOf).ToList();
new []{"components.securitySchemes.Basic", "components.securitySchemes.Webhook"}.Contains(error.Path) && error.ErrorType == ErrorType.OneOf).ToList();
foreach (ValidationError validationError in matchedError)
{
errors.Remove(validationError);
@ -968,9 +968,9 @@ namespace BTCPayServer.Tests
while (!completed || !confirmed)
{
var request = await callbackServer.GetNextRequest();
if (request.ContainsKey("event"))
if (request.Item1.ContainsKey("event"))
{
var evtName = request["event"]["name"].Value<string>();
var evtName = request.Item1["event"]["name"].Value<string>();
switch (evtName)
{
case InvoiceEvent.Created:

View File

@ -227,6 +227,7 @@ namespace BTCPayServer.Controllers.GreenField
AdditionalStatus = entity.ExceptionStatus,
Currency = entity.Currency,
Metadata = entity.Metadata.ToJObject(),
Webhooks = entity.Webhooks,
Checkout = new CreateInvoiceRequest.CheckoutOptions()
{
Expiration = entity.ExpirationTime - entity.InvoiceTime,

View File

@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
namespace BTCPayServer.Controllers.GreenField
{
@ -134,7 +135,9 @@ namespace BTCPayServer.Controllers.GreenField
PaymentTolerance = storeBlob.PaymentTolerance,
RedirectAutomatically = storeBlob.RedirectAutomatically,
PayJoinEnabled = storeBlob.PayJoinEnabled,
LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints
LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints,
Webhooks = storeBlob.Webhooks,
EventKey = storeBlob.EventSigner.PubKey.ToHex()
};
}
@ -170,6 +173,7 @@ namespace BTCPayServer.Controllers.GreenField
blob.RedirectAutomatically = restModel.RedirectAutomatically;
blob.PayJoinEnabled = restModel.PayJoinEnabled;
blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints;
blob.Webhooks = restModel.Webhooks;
model.SetStoreBlob(blob);
}

View File

@ -41,8 +41,7 @@ namespace BTCPayServer.Controllers
_cachedServerSettings = cachedServerSettings;
_fileProvider = webHostEnvironment.WebRootFileProvider;
SignInManager = signInManager;
}
}
private async Task<ViewResult> GoToApp(string appId, AppType? appType)
{
if (appType.HasValue && !string.IsNullOrEmpty(appId))

View File

@ -92,7 +92,7 @@ namespace BTCPayServer.Controllers
var details = InvoicePopulatePayments(invoice);
model.CryptoPayments = details.CryptoPayments;
model.Payments = details.Payments;
model.Webhooks = invoice.Webhooks;
return View(model);
}

View File

@ -164,9 +164,11 @@ namespace BTCPayServer.Controllers
if (invoice.Metadata != null)
entity.Metadata = InvoiceMetadata.FromJObject(invoice.Metadata);
invoice.Checkout ??= new CreateInvoiceRequest.CheckoutOptions();
invoice.Webhooks ??= new List<WebhookSubscription>();
entity.Currency = invoice.Currency;
entity.Price = invoice.Amount;
entity.SpeedPolicy = invoice.Checkout.SpeedPolicy ?? store.SpeedPolicy;
entity.Webhooks = invoice.Webhooks;
IPaymentFilter excludeFilter = null;
if (invoice.Checkout.PaymentMethods != null)
{
@ -197,7 +199,6 @@ namespace BTCPayServer.Controllers
}
entity.Status = InvoiceStatus.New;
HashSet<CurrencyPair> currencyPairsToFetch = new HashSet<CurrencyPair>();
var rules = storeBlob.GetRateRules(_NetworkProvider);
var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any()
if (invoicePaymentMethodFilter != null)
{

View File

@ -16,6 +16,7 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits;
@ -162,18 +163,45 @@ namespace BTCPayServer.Controllers
[HttpGet]
[Route("{storeId}/integrations")]
[Route("{storeId}/integrations/shopify")]
public IActionResult Integrations()
[Route("{storeId}/integrations/webhooks")]
public async Task<IActionResult> Integrations([FromServices] StoreRepository storeRepository)
{
var blob = CurrentStore.GetStoreBlob();
var vm = new IntegrationsViewModel {Shopify = blob.Shopify};
var vm = new IntegrationsViewModel {Shopify = blob.Shopify, EventPublicKey = blob.EventSigner.ToHex(), Webhooks = blob.Webhooks};
return View("Integrations", vm);
}
[HttpPost]
[Route("{storeId}/integrations/webhooks")]
public async Task<IActionResult> UpdateWebhooks([FromServices] IHttpClientFactory clientFactory,
IntegrationsViewModel vm, string command = "")
{
switch (command.ToLowerInvariant())
{
case "add":
vm.Webhooks.Add(new WebhookSubscription());
return View("Integrations", vm);
case string c when c.StartsWith("remove:", StringComparison.InvariantCultureIgnoreCase):
var index = int.Parse(c.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1), CultureInfo.InvariantCulture);
vm.Webhooks.RemoveAt(index);
return View("Integrations", vm);
case "save":
var blob = CurrentStore.GetStoreBlob();
blob.Webhooks = vm.Webhooks.Where(subscription => subscription.Url != null).ToList();
var store = CurrentStore;
store.SetStoreBlob(blob);
await _Repo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = "Webhooks saved";
break;
}
return RedirectToAction(nameof(Integrations), new {storeId = CurrentStore.Id});
}
[HttpPost]
[Route("{storeId}/integrations/shopify")]
public async Task<IActionResult> Integrations([FromServices] IHttpClientFactory clientFactory,
public async Task<IActionResult> UpdateShopify([FromServices] IHttpClientFactory clientFactory,
IntegrationsViewModel vm, string command = "", string exampleUrl = "")
{
if (!string.IsNullOrEmpty(exampleUrl))

View File

@ -12,6 +12,7 @@ using BTCPayServer.Rating;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Shopify.Models;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Data
@ -25,8 +26,14 @@ namespace BTCPayServer.Data
PaymentTolerance = 0;
ShowRecommendedFee = true;
RecommendedFeeBlockTarget = 1;
EventSigner = new Key();
Hints = new StoreHints {Wallet = true, Lightning = true};
}
public Key EventSigner { get; set; }
public List<WebhookSubscription> Webhooks { get; set; } = new List<WebhookSubscription>();
public ShopifySettings Shopify { get; set; }
[Obsolete("Use NetworkFeeMode instead")]

View File

@ -1,14 +1,19 @@
using System;
namespace BTCPayServer.Events
{
public class InvoiceIPNEvent
public class BitPayInvoiceIPNEvent
{
public InvoiceIPNEvent(string invoiceId, int? eventCode, string name)
public BitPayInvoiceIPNEvent(string invoiceId, int? eventCode, string name)
{
InvoiceId = invoiceId;
EventCode = eventCode;
Name = name;
Timestamp= DateTimeOffset.UtcNow;;
}
public DateTimeOffset Timestamp { get; set; }
public int? EventCode { get; set; }
public string Name { get; set; }

View File

@ -4,13 +4,15 @@ namespace BTCPayServer.Events
{
public class InvoiceDataChangedEvent
{
public InvoiceDataChangedEvent(InvoiceEntity invoice)
{
InvoiceId = invoice.Id;
State = invoice.GetInvoiceState();
Invoice = invoice;
}
public string InvoiceId { get; }
public InvoiceState State { get; }
public readonly InvoiceEntity Invoice;
public string InvoiceId => Invoice.Id;
public InvoiceState State => Invoice.GetInvoiceState();
public override string ToString()
{

View File

@ -288,7 +288,8 @@ namespace BTCPayServer
"://",
request.Host.ToUriComponent(),
request.PathBase.ToUriComponent(),
request.Path.ToUriComponent());
request.Path.ToUriComponent(),
request.QueryString.ToUriComponent());
}
public static string GetCurrentPath(this HttpRequest request)

View File

@ -22,7 +22,7 @@ using Newtonsoft.Json.Linq;
namespace BTCPayServer.HostedServices
{
public class InvoiceNotificationManager : IHostedService
public class BitPayIPNManager : IHostedService
{
readonly HttpClient _Client;
@ -33,7 +33,7 @@ namespace BTCPayServer.HostedServices
get; set;
}
public InvoicePaymentNotificationEventWrapper Notification
public BitPayInvoicePaymentNotificationEventWrapper Notification
{
get; set;
}
@ -45,7 +45,7 @@ namespace BTCPayServer.HostedServices
private readonly EmailSenderFactory _EmailSenderFactory;
private readonly StoreRepository _StoreRepository;
public InvoiceNotificationManager(
public BitPayIPNManager(
IHttpClientFactory httpClientFactory,
IBackgroundJobClient jobClient,
EventAggregator eventAggregator,
@ -64,7 +64,7 @@ namespace BTCPayServer.HostedServices
async Task Notify(InvoiceEntity invoice, InvoiceEvent invoiceEvent, bool extendedNotification)
{
var dto = invoice.EntityToDTO();
var notification = new InvoicePaymentNotificationEventWrapper()
var notification = new BitPayInvoicePaymentNotificationEventWrapper()
{
Data = new InvoicePaymentNotification()
{
@ -84,7 +84,7 @@ namespace BTCPayServer.HostedServices
ExchangeRates = dto.ExchangeRates,
OrderId = dto.OrderId
},
Event = new InvoicePaymentNotificationEvent()
Event = new BitPayInvoicePaymentNotificationEvent()
{
Code = invoiceEvent.EventCode,
Name = invoiceEvent.Name
@ -149,13 +149,13 @@ namespace BTCPayServer.HostedServices
{
var job = NBitcoin.JsonConverters.Serializer.ToObject<ScheduledJob>(invoiceData);
bool reschedule = false;
var aggregatorEvent = new InvoiceIPNEvent(job.Notification.Data.Id, job.Notification.Event.Code, job.Notification.Event.Name);
var aggregatorEvent = new BitPayInvoiceIPNEvent(job.Notification.Data.Id, job.Notification.Event.Code, job.Notification.Event.Name);
try
{
HttpResponseMessage response = await SendNotification(job.Notification, cancellationToken);
reschedule = !response.IsSuccessStatusCode;
aggregatorEvent.Error = reschedule ? $"Unexpected return code: {(int)response.StatusCode}" : null;
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
_EventAggregator.Publish<BitPayInvoiceIPNEvent>(aggregatorEvent);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@ -167,7 +167,7 @@ namespace BTCPayServer.HostedServices
catch (OperationCanceledException)
{
aggregatorEvent.Error = "Timeout";
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
_EventAggregator.Publish<BitPayInvoiceIPNEvent>(aggregatorEvent);
reschedule = true;
}
catch (Exception ex)
@ -183,7 +183,7 @@ namespace BTCPayServer.HostedServices
string message = String.Join(',', messages.ToArray());
aggregatorEvent.Error = $"Unexpected error: {message}";
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
_EventAggregator.Publish<BitPayInvoiceIPNEvent>(aggregatorEvent);
}
job.TryCount++;
@ -195,17 +195,17 @@ namespace BTCPayServer.HostedServices
}
}
public class InvoicePaymentNotificationEvent
public class BitPayInvoicePaymentNotificationEvent
{
[JsonProperty("code")]
public int Code { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
}
public class InvoicePaymentNotificationEventWrapper
public class BitPayInvoicePaymentNotificationEventWrapper
{
[JsonProperty("event")]
public InvoicePaymentNotificationEvent Event { get; set; }
public BitPayInvoicePaymentNotificationEvent Event { get; set; }
[JsonProperty("data")]
public InvoicePaymentNotification Data { get; set; }
[JsonProperty("extendedNotification")]
@ -215,7 +215,7 @@ namespace BTCPayServer.HostedServices
}
readonly Encoding UTF8 = new UTF8Encoding(false);
private async Task<HttpResponseMessage> SendNotification(InvoicePaymentNotificationEventWrapper notification, CancellationToken cancellationToken)
private async Task<HttpResponseMessage> SendNotification(BitPayInvoicePaymentNotificationEventWrapper notification, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage();
request.Method = HttpMethod.Post;
@ -226,8 +226,7 @@ namespace BTCPayServer.HostedServices
if (notification.ExtendedNotification)
{
jobj.Remove("extendedNotification");
jobj.Remove("notificationURL");
notificationString = jobj.ToString();
jobj.Remove("notificationURL"); notificationString = jobj.ToString();
}
else
{
@ -311,13 +310,13 @@ namespace BTCPayServer.HostedServices
{
leases.Add(_EventAggregator.Subscribe<InvoiceEvent>(async e =>
{
if (e.Invoice.NotificationURL is null && !string.IsNullOrEmpty(e.Invoice.NotificationEmail))
{
return;
}
var invoice = await _InvoiceRepository.GetInvoice(e.Invoice.Id);
if (invoice == null)
return;
List<Task> tasks = new List<Task>();
// Awaiting this later help make sure invoices should arrive in order
tasks.Add(SaveEvent(invoice.Id, e, InvoiceEventData.EventSeverity.Info));
// we need to use the status in the event and not in the invoice. The invoice might now be in another status.
if (invoice.FullNotifications)
@ -346,29 +345,10 @@ namespace BTCPayServer.HostedServices
}));
leases.Add(_EventAggregator.Subscribe<InvoiceDataChangedEvent>(async e =>
{
await SaveEvent(e.InvoiceId, e, InvoiceEventData.EventSeverity.Info);
}));
leases.Add(_EventAggregator.Subscribe<InvoiceStopWatchedEvent>(async e =>
{
await SaveEvent(e.InvoiceId, e, InvoiceEventData.EventSeverity.Info);
}));
leases.Add(_EventAggregator.Subscribe<InvoiceIPNEvent>(async e =>
{
await SaveEvent(e.InvoiceId, e, string.IsNullOrEmpty(e.Error)? InvoiceEventData.EventSeverity.Success: InvoiceEventData.EventSeverity.Error);
}));
return Task.CompletedTask;
}
private Task SaveEvent(string invoiceId, object evt, InvoiceEventData.EventSeverity severity)
{
return _InvoiceRepository.AddInvoiceEvent(invoiceId, evt, severity);
}
public Task StopAsync(CancellationToken cancellationToken)
{

View File

@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Events;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace BTCPayServer.HostedServices
{
public class GreenFieldWebhookManager : EventHostedServiceBase
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IBackgroundJobClient _backgroundJobClient;
private readonly StoreRepository _storeRepository;
private readonly MvcNewtonsoftJsonOptions _jsonOptions;
private readonly EventAggregator _eventAggregator;
public GreenFieldWebhookManager(EventAggregator eventAggregator, IHttpClientFactory httpClientFactory,
IBackgroundJobClient backgroundJobClient, StoreRepository storeRepository,
MvcNewtonsoftJsonOptions jsonOptions) : base(
eventAggregator)
{
_httpClientFactory = httpClientFactory;
_backgroundJobClient = backgroundJobClient;
_storeRepository = storeRepository;
_jsonOptions = jsonOptions;
_eventAggregator = eventAggregator;
}
public const string OnionNamedClient = "greenfield-webhook.onion";
public const string ClearnetNamedClient = "greenfield-webhook.clearnet";
protected override void SubscribeToEvents()
{
base.SubscribeToEvents();
Subscribe<InvoiceDataChangedEvent>();
Subscribe<InvoiceEvent>();
Subscribe<QueuedGreenFieldWebHook>();
}
private HttpClient GetClient(Uri uri)
{
return _httpClientFactory.CreateClient(uri.IsOnion() ? OnionNamedClient : ClearnetNamedClient);
}
readonly Dictionary<string, Task> _queuedSendingTasks = new Dictionary<string, Task>();
/// <summary>
/// Will make sure only one callback is called at once on the same key
/// </summary>
/// <param name="key"></param>
/// <param name="sendRequest"></param>
/// <returns></returns>
private async Task Enqueue(string key, Func<Task> sendRequest)
{
Task sending = null;
lock (_queuedSendingTasks)
{
if (_queuedSendingTasks.TryGetValue(key, out var executing))
{
var completion = new TaskCompletionSource<bool>();
sending = completion.Task;
_queuedSendingTasks.Remove(key);
_queuedSendingTasks.Add(key, sending);
_ = executing.ContinueWith(_ =>
{
sendRequest()
.ContinueWith(t =>
{
if (t.Status == TaskStatus.RanToCompletion)
{
completion.TrySetResult(true);
}
if (t.Status == TaskStatus.Faulted)
{
completion.TrySetException(t.Exception);
}
if (t.Status == TaskStatus.Canceled)
{
completion.TrySetCanceled();
}
}, TaskScheduler.Default);
}, TaskScheduler.Default);
}
else
{
sending = sendRequest();
_queuedSendingTasks.Add(key, sending);
}
_ = sending.ContinueWith(o =>
{
lock (_queuedSendingTasks)
{
_queuedSendingTasks.TryGetValue(key, out var executing2);
if (executing2 == sending)
_queuedSendingTasks.Remove(key);
}
}, TaskScheduler.Default);
}
await sending;
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
switch (evt)
{
case InvoiceEvent e
when new[] {InvoiceEvent.MarkedCompleted, InvoiceEvent.MarkedInvalid, InvoiceEvent.Created}
.Contains(e.Name):
await HandleInvoiceStatusChange(e.Invoice);
break;
case InvoiceDataChangedEvent e:
await HandleInvoiceStatusChange(e.Invoice);
break;
case QueuedGreenFieldWebHook e:
_ = Enqueue(e.Grouping, () => Send(e, cancellationToken));
break;
}
await base.ProcessEvent(evt, cancellationToken);
}
private async Task HandleInvoiceStatusChange(InvoiceEntity invoice)
{
var state = invoice.GetInvoiceState();
var blob = (await _storeRepository.FindStore(invoice.StoreId)).GetStoreBlob();
var key = blob.EventSigner;
var validWebhooks = blob.Webhooks.Concat(invoice.Webhooks ?? new List<WebhookSubscription>()).Where(
subscription =>
ValidWebhookForEvent(subscription, InvoiceStatusChangeEventPayload.EventType)).ToList();
if (validWebhooks.Any())
{
var payload = new InvoiceStatusChangeEventPayload()
{
Status = state.Status, AdditionalStatus = state.ExceptionStatus, InvoiceId = invoice.Id
};
foreach (WebhookSubscription webhookSubscription in validWebhooks)
{
var webHook = new GreenFieldEvent<InvoiceStatusChangeEventPayload>()
{
EventType = InvoiceStatusChangeEventPayload.EventType, Payload = payload
};
webHook.SetSignature(webhookSubscription.Url.ToString(), key);
_eventAggregator.Publish(new QueuedGreenFieldWebHook()
{
Event = webHook,
Subscription = webhookSubscription,
Grouping = webhookSubscription.ToString(),
});
}
}
}
private async Task Send(QueuedGreenFieldWebHook e, CancellationToken cancellationToken)
{
var client = GetClient(e.Subscription.Url);
var timestamp = DateTimeOffset.UtcNow;
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = e.Subscription.Url,
Content = new StringContent(
JsonConvert.SerializeObject(e.Event, Formatting.Indented, _jsonOptions.SerializerSettings),
Encoding.UTF8, "application/json")
};
var webhookEventResultError = "";
try
{
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromMinutes(1.0));
var result = await client.SendAsync(request, cts.Token);
if (result.IsSuccessStatusCode)
{
_eventAggregator.Publish(new GreenFieldWebhookResultEvent()
{
Error = webhookEventResultError, Hook = e, Timestamp = timestamp
});
return;
}
else
{
webhookEventResultError = $"Failure HTTP status code: {(int)result.StatusCode}";
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// When the JobClient will be persistent, this will reschedule the job for after reboot
_backgroundJobClient.Schedule((cancellation) => Send(e, cancellation), TimeSpan.FromMinutes(10.0));
return;
}
catch (OperationCanceledException)
{
webhookEventResultError = "Timeout";
}
catch (Exception ex)
{
List<string> messages = new List<string>();
while (ex != null)
{
messages.Add(ex.Message);
ex = ex.InnerException;
}
string message = string.Join(',', messages.ToArray());
webhookEventResultError = $"Unexpected error: {message}";
}
e.TryCount++;
_eventAggregator.Publish(new GreenFieldWebhookResultEvent()
{
Error =
$"{webhookEventResultError} (Tried {Math.Min(e.TryCount, QueuedGreenFieldWebHook.MaxTry)}/{QueuedGreenFieldWebHook.MaxTry})",
Hook = e,
Timestamp = timestamp
});
if (e.TryCount <= QueuedGreenFieldWebHook.MaxTry)
_backgroundJobClient.Schedule((cancellation) => Send(e, cancellation), TimeSpan.FromMinutes(10.0));
}
public bool ValidWebhookForEvent(WebhookSubscription webhookSubscription, string eventType)
{
return eventType.StartsWith(webhookSubscription.EventType, StringComparison.InvariantCultureIgnoreCase);
}
public class QueuedGreenFieldWebHook
{
public string Grouping { get; set; }
public WebhookSubscription Subscription { get; set; }
public IGreenFieldEvent Event { get; set; }
public int TryCount { get; set; } = 0;
public const int MaxTry = 6;
public override string ToString()
{
return string.Empty;
}
}
}
}

View File

@ -0,0 +1,78 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Events;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.HostedServices
{
public class InvoiceEventSaverService : EventHostedServiceBase
{
private readonly InvoiceRepository _invoiceRepository;
public InvoiceEventSaverService(EventAggregator eventAggregator, InvoiceRepository invoiceRepository) : base(
eventAggregator)
{
_invoiceRepository = invoiceRepository;
}
protected override void SubscribeToEvents()
{
Subscribe<InvoiceDataChangedEvent>();
Subscribe<InvoiceStopWatchedEvent>();
Subscribe<BitPayInvoiceIPNEvent>();
Subscribe<InvoiceEvent>();
Subscribe<GreenFieldWebhookResultEvent>();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
switch (evt)
{
case InvoiceDataChangedEvent e:
await SaveEvent(e.InvoiceId, e, InvoiceEventData.EventSeverity.Info);
break;
case InvoiceStopWatchedEvent e:
await SaveEvent(e.InvoiceId, e, InvoiceEventData.EventSeverity.Info);
break;
case InvoiceEvent e:
await SaveEvent(e.Invoice.Id, e, InvoiceEventData.EventSeverity.Info);
break;
case BitPayInvoiceIPNEvent e:
await SaveEvent(e.InvoiceId, e,
string.IsNullOrEmpty(e.Error)
? InvoiceEventData.EventSeverity.Success
: InvoiceEventData.EventSeverity.Error, e.Timestamp);
break;
case GreenFieldWebhookResultEvent e when e.Hook.Event is GreenFieldEvent<InvoiceStatusChangeEventPayload> greenFieldEvent:
await SaveEvent(greenFieldEvent.PayloadParsed.InvoiceId, e,
string.IsNullOrEmpty(e.Error)
? InvoiceEventData.EventSeverity.Success
: InvoiceEventData.EventSeverity.Error, e.Timestamp);
break;
}
}
private Task SaveEvent(string invoiceId, object evt, InvoiceEventData.EventSeverity severity, DateTimeOffset? timeOffset = null)
{
return _invoiceRepository.AddInvoiceEvent(invoiceId, evt, severity, timeOffset);
}
}
public class GreenFieldWebhookResultEvent
{
public GreenFieldWebhookManager.QueuedGreenFieldWebHook Hook { get; set; }
public string Error { get; set; }
public override string ToString()
{
return string.IsNullOrEmpty(Error)
? $"Webhook {Hook.Subscription.EventType} sent to {Hook.Subscription.Url}"
: $"Error while sending webhook {Hook.Subscription.EventType} to {Hook.Subscription.Url}: {Error}";
}
public DateTimeOffset Timestamp { get; set; }
}
}

View File

@ -234,7 +234,12 @@ namespace BTCPayServer.Hosting
services.AddScoped<NotificationSender>();
services.AddSingleton<IHostedService, NBXplorerWaiters>();
services.AddSingleton<IHostedService, InvoiceNotificationManager>();
services.AddSingleton<IHostedService, BitPayIPNManager>();
services.AddSingleton<IHostedService, InvoiceEventSaverService>();
services.AddSingleton<IHostedService, GreenFieldWebhookManager>();
services.AddHttpClient(GreenFieldWebhookManager.OnionNamedClient)
.ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true);
services.AddHttpClient(GreenFieldWebhookManager.ClearnetNamedClient);
services.AddSingleton<IHostedService, InvoiceWatcher>();
services.AddSingleton<IHostedService, RatesHostedService>();
services.AddSingleton<IHostedService, BackgroundJobSchedulerHostedService>();
@ -256,7 +261,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.AddShopify();
#if DEBUG
services.AddSingleton<INotificationHandler, JunkNotification.Handler>();

View File

@ -10,6 +10,7 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
namespace BTCPayServer.Hosting
{
@ -39,6 +40,12 @@ namespace BTCPayServer.Hosting
{
await Migrate(cancellationToken);
var settings = (await _Settings.GetSettingAsync<MigrationSettings>()) ?? new MigrationSettings();
if (!settings.StoreEventSignerCreatedCheck)
{
await StoreEventSignerCreatedCheck();
settings.StoreEventSignerCreatedCheck = true;
await _Settings.UpdateSetting(settings);
}
if (!settings.DeprecatedLightningConnectionStringCheck)
{
await DeprecatedLightningConnectionStringCheck();
@ -231,5 +238,17 @@ retry:
await ctx.SaveChangesAsync();
}
}
private async Task StoreEventSignerCreatedCheck()
{
await using var ctx = _DBContextFactory.CreateContext();
foreach (var store in await ctx.Stores.ToArrayAsync())
{
var blob = store.GetStoreBlob();
blob.EventSigner = new Key();
store.SetStoreBlob(blob);
}
await ctx.SaveChangesAsync();
}
}
}

View File

@ -59,7 +59,7 @@ namespace BTCPayServer.Models.InvoicingModels
}
[Uri]
[DisplayName("Notification Url")]
[DisplayName("Invoice Status Change Webhook Url")]
public string NotificationUrl
{
get; set;

View File

@ -60,6 +60,8 @@ namespace BTCPayServer.Models.InvoicingModels
get; set;
} = new List<CryptoPayment>();
public List<WebhookSubscription> Webhooks { get; set; }
public string State
{
get; set;

View File

@ -1,15 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Shopify.Models;
using static BTCPayServer.Data.StoreBlob;
namespace BTCPayServer.Models.StoreViewModels
{
public class IntegrationsViewModel
{
public ShopifySettings Shopify { get; set; }
public string EventPublicKey { get; set; }
public List<WebhookSubscription> Webhooks { get; set; } = new List<WebhookSubscription>();
}
}

View File

@ -208,7 +208,7 @@ namespace BTCPayServer.Services.Invoices
public BTCPayNetworkProvider Networks { get; set; }
public const int InternalTagSupport_Version = 1;
public const int GreenfieldInvoices_Version = 2;
public const int Lastest_Version = 2;
public const int Latest_Version = 2;
public int Version { get; set; }
public string Id
{
@ -464,6 +464,7 @@ namespace BTCPayServer.Services.Invoices
public List<InvoiceEventData> Events { get; internal set; }
public double PaymentTolerance { get; set; }
public bool Archived { get; set; }
public List<WebhookSubscription> Webhooks { get; set; } = new List<WebhookSubscription>();
public bool IsExpired()
{

View File

@ -64,7 +64,7 @@ retry:
return new InvoiceEntity()
{
Networks = _Networks,
Version = InvoiceEntity.Lastest_Version,
Version = InvoiceEntity.Latest_Version,
InvoiceTime = DateTimeOffset.UtcNow,
Metadata = new InvoiceMetadata()
};
@ -306,17 +306,17 @@ retry:
}
}
public async Task AddInvoiceEvent(string invoiceId, object evt, InvoiceEventData.EventSeverity severity)
public async Task AddInvoiceEvent(string invoiceId, object evt, InvoiceEventData.EventSeverity severity, DateTimeOffset? timestamp = null)
{
await using var context = _ContextFactory.CreateContext();
await context.InvoiceEvents.AddAsync(new InvoiceEventData()
{
Severity = severity,
InvoiceDataId = invoiceId,
Message = evt.ToString(),
Timestamp = DateTimeOffset.UtcNow,
UniqueId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(10))
});
{
Severity = severity,
InvoiceDataId = invoiceId,
Message = evt.ToString(),
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
UniqueId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(10))
});
try
{
await context.SaveChangesAsync();

View File

@ -2,6 +2,7 @@ namespace BTCPayServer.Services
{
public class MigrationSettings
{
public bool StoreEventSignerCreatedCheck { get; set; }
public bool UnreachableStoreCheck { get; set; }
public bool DeprecatedLightningConnectionStringCheck { get; set; }
public bool ConvertMultiplierToSpread { get; set; }

View File

@ -182,13 +182,6 @@ namespace BTCPayServer.Services.Stores
public async Task<StoreData> CreateStore(string ownerId, string name)
{
var store = new StoreData() { StoreName = name };
var blob = store.GetStoreBlob();
blob.Hints = new Data.StoreBlob.StoreHints
{
Wallet = true,
Lightning = true
};
store.SetStoreBlob(blob);
await CreateStore(ownerId, store);
return store;
}

View File

@ -23,6 +23,6 @@
</head>
<body>
<redoc spec-url="@Url.ActionLink("Swagger")"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.24/bundles/redoc.standalone.js" integrity="sha384-ZO+OTQZMsYIcoraCBa8iJW/5b2O8K1ujHmRfOwSvpVBlHUeKq5t3/kh1p8JQJ99X" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.45/bundles/redoc.standalone.js" crossorigin="anonymous"></script>
</body>
</html>

View File

@ -1,3 +1,4 @@
@using BTCPayServer.Client.Models
@model InvoiceDetailsModel
@{
ViewData["Title"] = "Invoice " + Model.Id;
@ -99,14 +100,36 @@
<th>Total fiat due</th>
<td>@Model.Fiat</td>
</tr>
<tr>
<th>Notification Url</th>
<td>@Model.NotificationUrl</td>
</tr>
@if (!string.IsNullOrEmpty(Model.NotificationUrl))
{
<tr>
<th>Notification Url</th>
<td>@Model.NotificationUrl</td>
</tr>
}
<tr>
<th>Redirect Url</th>
<td><a href="@Model.RedirectUrl">@Model.RedirectUrl</a></td>
</tr>
@if (Model.Webhooks?.Any() is true)
{
<tr>
<th>Webhooks</th>
<td>
<ul class="list-group list-group-flush">
@foreach (var webhook in Model.Webhooks)
{
<li class="list-group-item ">
<div class="d-flex justify-content-between">
<a href="@webhook.Url" target="_blank" class="text-truncate" style="max-width: 200px;" data-toggle="tooltip" title="@webhook.Url">@webhook.Url</a>
<span class="text-muted">@webhook.EventType</span>
</div>
</li>
}
</ul>
</td>
</tr>
}
</table>
</div>
<div class="col-md-6">

View File

@ -4,7 +4,7 @@
ViewData.SetActivePageAndTitle(StoreNavPages.Integrations, "Integrations");
}
<partial name="_StatusMessage" />
<partial name="_StatusMessage"/>
@if (!ViewContext.ModelState.IsValid)
{
@ -17,23 +17,43 @@
<div class="row">
<div class="col-md-8">
<partial name="Integrations/Shopify"/>
<partial name="Integrations/Shopify"/>
<partial name="Integrations/Webhooks"/>
<h4 class="mb-3 mt-5">
Other Integrations
</h4>
<p>
Take a look at documentation for the list of other integrations we support and the directions on how to enable them:
<ul>
<li><a href="https://docs.btcpayserver.org/WooCommerce/" target="_blank">WooCommerce</a></li>
<li><a href="https://docs.btcpayserver.org/Drupal/" target="_blank">Drupal</a></li>
<li><a href="https://docs.btcpayserver.org/Magento/" target="_blank">Magento</a></li>
<li><a href="https://docs.btcpayserver.org/PrestaShop/" target="_blank">PrestaShop</a></li>
<li>
<a href="https://docs.btcpayserver.org/WooCommerce/" target="_blank">WooCommerce</a>
</li>
<li>
<a href="https://docs.btcpayserver.org/Drupal/" target="_blank">Drupal</a>
</li>
<li>
<a href="https://docs.btcpayserver.org/Magento/" target="_blank">Magento</a>
</li>
<li>
<a href="https://docs.btcpayserver.org/PrestaShop/" target="_blank">PrestaShop</a>
</li>
</ul>
</p>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
<style>
.remove-btn {
font-size: 1.5rem;
border-radius: 0;
}
.remove-btn:hover {
background-color: #CCCCCC;
}
</style>
}

View File

@ -4,7 +4,7 @@
var shopifyCredsSet = shopify?.IntegratedAt.HasValue is true;
var shopifyUrl = shopify is null? null: !shopify?.ShopName?.Contains(".") is true ? $"https://{shopify.ShopName}.myshopify.com" : shopify.ShopName;
}
<form method="post" id="shopifyForm">
<form method="post" id="shopifyForm" asp-action="UpdateShopify" asp-route-storeId="@this.Context.GetRouteValue("storeId")">
<h4 class="mb-3">
Shopify
<a href="https://docs.btcpayserver.org/Shopify" target="_blank">

View File

@ -0,0 +1,55 @@
@using BTCPayServer.Client.Events
@model IntegrationsViewModel
<form method="post" asp-action="UpdateWebhooks" asp-route-storeId="@this.Context.GetRouteValue("storeId")">
<div class="list-group mb-2 mt-5">
<div class="list-group-item">
<h4 class="mb-1">
Webhooks
<button type="submit" name="command" value="add" class="ml-1 btn btn-secondary btn-sm ">Add webhook</button>
</h4>
</div>
@for (var index = 0; index < Model.Webhooks.Count; index++)
{
<div class="list-group-item p-0 pl-lg-2">
<div class="row">
<div class="col-sm-12 col-md-12 col-lg-10 py-2 ">
<div class="form-group">
<label asp-for="Webhooks[index].Url" class="control-label"></label>
<input asp-for="Webhooks[index].Url" class="form-control"/>
<span asp-validation-for="Webhooks[index].Url" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Webhooks[index].EventType"></label>
<select asp-for="Webhooks[index].EventType" class="form-control">
<option value="@InvoiceStatusChangeEventPayload.EventType">Invoice status change</option>
</select>
<span asp-validation-for="Webhooks[index].EventType" class="text-danger"></span>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
<button type="submit" title="Remove webhook" name="command" value="@($"remove:{index}")"
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
Remove
</button>
<button type="submit" title="Remove webhook" name="command" value="@($"remove:{index}")"
class="d-none d-lg-block remove-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
</button>
</div>
</div>
</div>
}
<div class="list-group-item border-top-0">
<button type="submit" name="command" value="save" class="ml-1 btn btn-primary">Save</button>
</div>
</div>
<h4 class="mb-3 mt-5">
Webhook Verification Key
</h4>
<p>When receiving webhook events from the Greenfield API related to this store, you can verify the signature against this public key.</p>
<input type="text" class="form-control" readonly asp-for="EventPublicKey"/>
</form>

View File

@ -506,6 +506,13 @@
}
],
"description": "Additional settings to customize the checkout flow"
},
"webhooks": {
"type": "array",
"description": "A list of webhook subscriptions",
"items": {
"$ref": "#/components/schemas/WebhookSubscription"
}
}
}
},
@ -568,6 +575,68 @@
}
}
},
"x-webhooks": {
"Invoice Status Changed": {
"post": {
"summary": "Invoice Status Changed",
"description": "Information on when an invoice status has changed.",
"operationId": "invoice_status",
"tags": [
"Invoices"
],
"security": [
{
"Webhook": ["store"]
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"eventType": {
"type": "string",
"example": "invoice_status",
"description": "The event type"
},
"payload": {
"type": "object",
"properties": {
"invoiceId": {
"type": "string",
"example": "9dD1z86QWCy2rNPGT9KBtD",
"description": "The invoice id"
},
"status": {
"$ref": "#/components/schemas/InvoiceStatus",
"description": "The status of the invoice"
},
"additionalStatus": {
"$ref": "#/components/schemas/InvoiceAdditionalStatus",
"description": "a secondary status of the invoice"
}
}
},
"signature": {
"type": "string",
"example": "HwQQDD37sIhar3o1OcY2msxecHhdubCyliOp5qEpHm9YErvKGsFl2iVF8QEOafrIHM4INB27aGLPJkEtRc9s1r4=",
"description": "A signature signed by store to verify the event was sent from your store."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Return a 200 status to indicate that the data was received successfully"
}
}
}
}
},
"tags": [
{
"name": "Invoices"

View File

@ -64,7 +64,12 @@
"name": "Authorization",
"in": "header",
"scheme": "Basic"
}
},
"Webhook":{
"description": "BTCPay Server supports verifying webhooks through various generated private keys. Depending on the event, the private key can be found in the store, server, user, etc.\n\nThe signature for events is generated by hashing `{url}_{payload}`, which has been lowercased and all spaces, tabs and new lines stripped out, where `url` is the full url the HTTP POST request was sent to and `payload` is the `payload` json object in the body.",
"name": "Webhooks",
"type": "The public key is a Bitcoin public key in hex format."
}
}
},
"security": [

View File

@ -264,6 +264,11 @@
"type": "string",
"description": "The id of the store",
"nullable": false
},
"eventKey": {
"type": "string",
"format": "hex",
"description": "The public key used to sign webhooks related to this store."
}
}
}
@ -377,6 +382,13 @@
"type": "boolean",
"default": false,
"description": "Should private route hints be included in the lightning payment of the checkout page."
},
"webhooks": {
"type": "array",
"description": "A list of webhook subscriptions",
"items": {
"$ref": "#/components/schemas/WebhookSubscription"
}
}
}
},

View File

@ -0,0 +1,25 @@
{
"components": {
"schemas": {
"WebhookSubscription": {
"type": "object",
"additionalProperties": false,
"properties": {
"eventType": {
"type": "string",
"example": "invoice_status",
"description": "The event the webhook is subscribed to",
"enum": [
"invoice_status"
]
},
"url": {
"type": "string",
"format": "url",
"description": "The url the webhook HTTP POST request will be sent to"
}
}
}
}
}
}