// ROKI Connect - C# / .NET client. // // Generated from openapi.yaml v2.0.0. Do not edit by hand: run sdk/generate.mjs. // Client version 2.0.0, build 12e0e82 - compare the build against yours before // deciding whether your copy is current. // // Targets .NET 6 and later. No NuGet packages: System.Text.Json and HttpClient ship with the // platform, and a payments client that drags dependencies eventually collides with the ones the // merchant's application already has. // // var roki = new RokiConnect(Environment.GetEnvironmentVariable("ROKI_SECRET_KEY")!); // var payment = await roki.CreatePaymentAsync(new Dictionary // { // ["amount"] = 150.00m, // ["external_reference"] = order.Id.ToString(), // ["name"] = $"Order #{order.Id}", // }); // return Redirect(payment["checkout_url"]!.GetValue()); // // In ASP.NET Core, register it once and inject it - one HttpClient per process, never one per // request, which exhausts sockets under load: // // builder.Services.AddHttpClient("roki"); // builder.Services.AddSingleton(sp => new RokiConnect( // builder.Configuration["Roki:SecretKey"]!, // new RokiOptions { HttpClient = sp.GetRequiredService().CreateClient("roki") })); // // Responses come back as JsonNode rather than as a generated model per endpoint. That is // deliberate: this API adds fields without warning - `warnings` itself arrived that way - and a // strongly typed model silently drops what it does not know. // // The environment is the key: sk_test_ is sandbox, sk_live_ is production. Same routes. #nullable enable using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace Roki.Connect; /// Anything the API rejected, plus the local checks that run before the request leaves. public class RokiException : Exception { public int Status { get; } public JsonNode? Response { get; } public RokiException(string message, int status = 0, JsonNode? response = null) : base(message) { Status = status; Response = response; } /// Field-level validation errors, when the API sent them. public IReadOnlyDictionary ValidationErrors { get { var salida = new Dictionary(); if (Response?["errors"] is JsonObject errores) { foreach (var par in errores) { salida[par.Key] = par.Value is JsonArray arr ? arr.Select(x => x?.ToString() ?? string.Empty).ToArray() : new[] { par.Value?.ToString() ?? string.Empty }; } } return salida; } } } public sealed class RokiOptions { public string? BaseUrl { get; set; } public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); /// /// Reject unknown fields before sending. Leave this on: the API answers 201 to a misspelled /// field and ignores it, so a typo becomes a silent production bug instead of a local error. /// public bool Strict { get; set; } = true; public string Language { get; set; } = "en"; /// Supply your own so ASP.NET Core can pool connections. public HttpClient? HttpClient { get; set; } /// /// Called whenever the API reports it ignored a field. With Strict on, this firing means the /// spec this SDK was generated from is ahead of the live API - regenerate. /// public Action, string, string>? OnWarnings { get; set; } } public sealed class RokiConnect { /// This client's own version. Plain semver, so NuGet and range checks can order it. /// /// The fingerprint deliberately does not live here. Pinned to the version it made /// version_compare("2.0.0+build.12e0e82", "2.0.0", ">=") false in the PHP twin of this file, /// and the WooCommerce plugin started telling merchants to update an SDK that was current. /// public const string Version = "2.0.0"; /// Fingerprint of the contract, alias map and templates this file came out of. /// /// Not a version: it does not order anything. It answers whether two copies are the same file. /// It rides along in the User-Agent, which is free text nobody sorts. /// public const string Build = "12e0e82"; /// The openapi.yaml version this file was generated from. public const string ApiVersion = "2.0.0"; private const string DefaultBase = "https://aura.roki.systems/api/connect/v1"; /// Field names from other gateways, mapped to what ROKI actually calls them. public static readonly IReadOnlyDictionary FieldAliases = new Dictionary { ["amount"] = "In Stripe this is \"amount\"; in ROKI it is \"amount\". Same name, different unit: ROKI's amount is decimal units (1500.00 = L 1,500.00, minimum 0.01), so divide Stripe's integer by 100 before sending, and enforce your own ceiling because ROKI enforces no maximum and would silently create a...", ["amount_cents"] = "In Stripe this is \"amount_cents\"; in ROKI it is \"amount\". ROKI has no cents field anywhere: convert to decimal units and send amount; this is one of the few wrong names that fails loudly rather than silently, returning 422 'El campo amount es obligatorio.' because amount is required.", ["currency"] = "In Stripe this is \"currency\"; in ROKI it is \"currency_code\". POST /payments wants the ISO 4217 *numeric* code as a string ('340' = HNL), not the alphabetic one; the alphabetic form ('HNL') is accepted only on /payments/token-charge, /payment-methods/{id}/charge and /confirm, and the response echoes...", ["payment_method_types"] = "Stripe has \"payment_method_types\", ROKI has no equivalent. ROKI is card-only and the integration mode is chosen by which endpoint you call - POST /payments for hosted checkout, POST /confirm for embedded card fields, POST /payment-methods/{id}/charge for a saved card - so drop the field entirely,...", ["success_url"] = "In Stripe this is \"success_url\"; in ROKI it is \"success_url\". Identical name on POST /payments, but the embedded-components endpoint POST /confirm calls it success_redirect_url; only http/https is accepted (custom app schemes like myapp:// return 422), and a customer landing there is never proof of...", ["cancel_url"] = "In Stripe this is \"cancel_url\"; in ROKI it is \"cancel_url\". Same name on POST /payments; the embedded-components equivalent is failed_redirect_url on POST /confirm, which means 'the charge failed', not 'the customer backed out', so the two are not interchangeable.", ["customer_email"] = "In Stripe this is \"customer_email\"; in ROKI it is \"customer.email\". In ROKI it lives inside the optional customer prefill object (customer.email, alongside name, phone, identity_number), it creates no customer record, it stays editable unless you also send lock_customer_fields: true, and it is echoed back...", ["metadata"] = "In Stripe and Mercado Pago this is \"metadata\"; in ROKI it is \"metadata\". Same name and same purpose - returned untouched on retrieval and in every webhook, never shown to the customer - but it must be a JSON object or you get 422 ('El campo metadata debe ser un array.'), and when you send none ROKI may return...", ["client_reference_id"] = "In Stripe this is \"client_reference_id\"; in ROKI it is \"external_reference\". ROKI's external_reference is required (max 191) and, despite the name, is NOT unique - two creates with the same value produce two separately payable links - so duplicate protection has to come from Idempotency-Key; you can filter by it...", ["capture_method"] = "Stripe has \"capture_method\", ROKI has no equivalent. ROKI has no authorize/capture split and no capture endpoint - every approved charge is captured immediately - so express reversal instead: try POST /payments/{transaction_id}/void (full amount only, before settlement) and fall back to POST...", ["receipt_email"] = "Stripe has \"receipt_email\", ROKI has no equivalent. ROKI never emails a receipt from the API: fetch GET /payments/{transaction_id}/receipt for the public receipt_url (or /receipt/download for the PDF) and deliver it yourself - and be aware customer.email only prefills the checkout form, it...", ["setup_future_usage"] = "Stripe has \"setup_future_usage\", ROKI has no equivalent. You cannot request card saving from the ROKI API at all - the customer must tick 'save my card' on ROKI's own checkout, after which the payment_method.saved webhook delivers an opaque pm_* that you charge later via POST...", ["payment_intent"] = "In Stripe this is \"payment_intent\"; in ROKI it is \"transaction_id\". ROKI splits this into two handles: the numeric id for GET /payments/{id}, and the UUID transaction_id - null until the payment is actually charged - which is what void, refund and receipts require; passing the numeric id to those routes...", ["customer"] = "Stripe has \"customer\", ROKI has no equivalent. ROKI has no Customer resource and no customer ids, so there is nothing to pass here - identify people by the data itself: send the inline customer prefill object (name, email, phone, identity_number) on POST /payments, and look up saved...", ["description"] = "In Stripe and Mercado Pago this is \"description\"; in ROKI it is \"description\". Same name but capped at 120 characters in ROKI, and it is not the label the customer sees at checkout - that is the required name field (max 191), which Stripe has no equivalent for, so a straight port leaves your checkout page unlabelled.", ["statement_descriptor"] = "Stripe and Mercado Pago have \"statement_descriptor\", ROKI has no equivalent. ROKI exposes no per-payment statement descriptor - it comes from the merchant's terminal configuration - so remove the field rather than sending it, because it would be silently ignored with a 201; name and description only affect what the...", ["line_items"] = "Stripe has \"line_items\", ROKI has no equivalent. ROKI takes one pre-computed total: sum the cart in your own backend and send amount in decimal units plus a single name label, keep the per-line breakdown in metadata for reconciliation, and use sales_tax_type/sales_tax_value when you need...", ["mode"] = "Stripe has \"mode\", ROKI has no equivalent. ROKI has no mode parameter and no subscription engine - POST /payments is always a one-time hosted checkout - so drive recurring billing from your own scheduler by calling POST /payments/token-charge with a saved payment_token each cycle,...", ["url"] = "In Stripe this is \"url\"; in ROKI it is \"checkout_url\". ROKI returns checkout_url on the 201 from POST /payments - redirect to the exact string returned and never hardcode, allow-list or pattern-match the domain or slug, because the live value (aura.roki.systems/pay/link/{12-char lowercase...", ["amount_total"] = "In Stripe this is \"amount_total\"; in ROKI it is \"total\". ROKI's total is authoritative and in decimal units (subtotal + sales_tax_amount + service_fee_amount) - read it back from the response instead of recomputing it, and remember a customer-selected tip is chosen at checkout, so the amount...", ["expires_at"] = "In Stripe this is \"expires_at\"; in ROKI it is \"expires_at\". Same name, completely different format: ROKI wants a string like '2026-08-13 18:00:00' (or ISO 8601) in Honduras local time UTC-6 with no offset marker and it must be in the future or you get 422 - passing a Unix timestamp, or parsing the...", ["payment_method"] = "In Stripe this is \"payment_method\"; in ROKI it is \"payment_token\". ROKI's saved cards carry the same pm_ prefix, which makes this the most convincing wrong guess in the migration: the field is payment_token in the body of POST /payments/token-charge (or the {payment_method_id} path segment of POST...", ["application_fee_amount"] = "Stripe has \"application_fee_amount\", ROKI has no equivalent. ROKI has no platform or marketplace fee field; its only fee flag is the boolean service_fee_enabled, which means something different - it passes ROKI's own processing costs to the customer by reverse calculation so the merchant nets amount...", ["locale"] = "In Stripe this is \"locale\"; in ROKI it is \"Accept-Language\". ROKI has no body field for this - send the Accept-Language: es|en header instead (Spanish is the default for a missing, empty or unsupported value) - but note its narrower reach: it localizes API and validation messages only, routing...", ["items"] = "Mercado Pago has \"items\", ROKI has no equivalent. ROKI has no line items at all - sum the cart in your own backend and send one amount, and if you need the breakdown preserved put it in metadata, which ROKI returns untouched on retrieval and in every webhook.", ["unit_price"] = "Mercado Pago has \"unit_price\", ROKI has no equivalent. Nothing per-unit exists in ROKI - multiply by quantity, sum every item, and send the result as amount (decimal units, minimum 0.01, no maximum enforced by the API, so cap it on your side).", ["quantity"] = "Mercado Pago has \"quantity\", ROKI has no equivalent. Fold quantity into the total before calling ROKI, and never leave the key in the body as a hopeful extra: ROKI silently ignores unknown fields and still returns 201, so the mistake is invisible.", ["transaction_amount"] = "In Mercado Pago this is \"transaction_amount\"; in ROKI it is \"amount\". The closest 1:1 match in the whole migration - also decimal units and not cents (150.50 = L 150.50), also required, minimum 0.01 - but ROKI may add sales tax, tip and service fee on top, so the authoritative charged figure is total in the...", ["payer"] = "In Mercado Pago this is \"payer\"; in ROKI it is \"customer\". ROKI customer accepts only name, email, phone and identity_number (no surname, no address), and it is only a PREFILL of the hosted checkout that the customer can still edit unless you also send lock_customer_fields: true.", ["external_reference"] = "In Mercado Pago this is \"external_reference\"; in ROKI it is \"external_reference\". Identical name but REQUIRED in ROKI (max 191) and explicitly NOT unique - sending it twice without an Idempotency-Key creates two separate payable payments - and it is filterable via GET /payments?external_reference=.", ["notification_url"] = "Mercado Pago has \"notification_url\", ROKI has no equivalent. ROKI webhooks are not per payment - register the endpoint URL and copy its signing secret once per environment at https://aura.roki.systems/merchant/connect/webhooks, then verify the ROKI-Signature HMAC-SHA256 over timestamp + \".\" +...", ["back_urls"] = "Mercado Pago has \"back_urls\", ROKI has no equivalent. There is no nested object in ROKI - the two flat top-level fields success_url and cancel_url replace it, and both accept only http/https (a custom app scheme like myapp://done returns 422, so mobile return trips need Universal Links / App...", ["auto_return"] = "Mercado Pago has \"auto_return\", ROKI has no equivalent. There is no switch to set - the ROKI hosted checkout always offers the return to success_url - and as in Mercado Pago the redirect is never proof of a charge, so treat it purely as UX and confirm server-side.", ["init_point"] = "In Mercado Pago this is \"init_point\"; in ROKI it is \"checkout_url\". The clean equivalent and the heart of the migration - ROKI returns it straight from POST /payments with no preference step, there is exactly one URL for both environments, and the slug is a bare 12-character lowercase string with no plink_...", ["sandbox_init_point"] = "Mercado Pago has \"sandbox_init_point\", ROKI has no equivalent. ROKI has no second URL - the environment is decided solely by the API key prefix (sk_test_ = sandbox, sk_live_ = production) over identical routes, so use checkout_url in both and just swap the key.", ["preference_id"] = "In Mercado Pago this is \"preference_id\"; in ROKI it is \"id\". ROKI has no preference object - POST /payments creates the payment itself - and the returned numeric id serves only GET /payments/{id}, so persist it, because the only other way back to a payment is paging GET /payments.", ["payment_method_id"] = "Mercado Pago has \"payment_method_id\", ROKI has no equivalent. The most dangerous false friend in this table - ROKI does have a payment_method_id, but it is a URL PATH PARAMETER carrying an opaque saved-card reference (pm_7k2n9xqf31ab), not a brand selector, and ROKI Connect offers no method selection...", ["token"] = "In Mercado Pago this is \"token\"; in ROKI it is \"payment_token\". Same architecture, but one ROKI name covers two non-interchangeable values: mode 2 sends the iframe SDK's tok_* to POST https://aura.roki.systems/api/connect/embed/confirm alongside publishable_key (note the base is /api/connect/embed, not...", ["installments"] = "Mercado Pago has \"installments\", ROKI has no equivalent. ROKI Connect exposes no installment plans whatsoever and there is no workaround - and because unknown fields are silently dropped, leaving installments in the body returns 201 while the customer is charged in full, so strip it out...", ["currency_id"] = "In Mercado Pago this is \"currency_id\"; in ROKI it is \"currency_code\". Top-level rather than per-item, and inconsistent inside ROKI itself: POST /payments requires the NUMERIC ISO 4217 code (\"340\" = HNL) while POST /payments/token-charge and the embed confirm accept the alphabetic \"HNL\" - and only currencies...", ["expires"] = "Mercado Pago has \"expires\", ROKI has no equivalent. ROKI has no on/off switch - simply send expires_at to set an expiry, or omit it entirely for a link with no expiry.", ["expiration_date_to"] = "In Mercado Pago this is \"expiration_date_to\"; in ROKI it is \"expires_at\". Must be in the future and is interpreted in Honduras time (UTC-6) with NO offset in the string in either direction, so parsing expires_at, created_at or paid_at as UTC shifts every value by six hours - enough to make a live link look...", ["capture"] = "Mercado Pago has \"capture\", ROKI has no equivalent. ROKI has no auth/capture split - every approved charge captures - so the reversal path replaces it: try POST /payments/{transaction_id}/void (full amount only, pre-settlement) and fall back to POST /payments/{transaction_id}/refund (full...", ["binary_mode"] = "Mercado Pago has \"binary_mode\", ROKI has no equivalent. Not configurable in ROKI - a payment is pending until it is charged, and in mode 2 a 3-D Secure challenge returns status: \"pending\" with an authentication_url the customer must complete, the final outcome arriving by webhook.", ["status_detail"] = "Mercado Pago has \"status_detail\", ROKI has no equivalent. ROKI returns only status (pending, paid, partially_refunded, refunded, voided, expired, disabled); decline detail arrives in a different shape entirely, as processor-passthrough IsoResponseCode plus a capitalized Errors[] array of {Code,...", }; private static readonly HttpClient Compartido = new HttpClient(); private static readonly Regex FirmaRe = new Regex( @"t=(\d+)\s*,\s*v1=([a-f0-9]{64})", RegexOptions.IgnoreCase | RegexOptions.Compiled); private readonly string _secretKey; private readonly string _base; private readonly TimeSpan _timeout; private readonly bool _strict; private readonly string _language; private readonly HttpClient _http; private readonly Action, string, string> _onWarnings; public RokiConnect(string secretKey, RokiOptions? options = null) { if (string.IsNullOrWhiteSpace(secretKey)) { throw new RokiException("Missing secret key. Read it from configuration, never hard-code it."); } if (secretKey.StartsWith("pk_", StringComparison.Ordinal)) { throw new RokiException("That is a publishable key. Server calls need the sk_ secret key."); } options ??= new RokiOptions(); _secretKey = secretKey; _base = (options.BaseUrl ?? DefaultBase).TrimEnd('/'); _timeout = options.Timeout; _strict = options.Strict; _language = options.Language; _http = options.HttpClient ?? Compartido; _onWarnings = options.OnWarnings ?? ((w, method, path) => Console.Error.WriteLine($"ROKI {method} {path} ignored fields: {string.Join(", ", w)}")); } /// True when this client is talking to the sandbox. public bool IsSandbox => _secretKey.StartsWith("sk_test_", StringComparison.Ordinal); /// Which merchant does this key belong to /// openapi operationId: getMerchant public Task GetMerchantAsync(CancellationToken cancellationToken = default) { return RequestAsync( "GET", $"/me", null, null, null, null, cancellationToken); } /// List payments /// openapi operationId: listPayments public Task ListPaymentsAsync(IDictionary? query = null, CancellationToken cancellationToken = default) { return RequestAsync( "GET", $"/payments", null, query, null, null, cancellationToken); } /// Create a payment /// openapi operationId: createPayment public Task CreatePaymentAsync(IDictionary body, string? idempotencyKey = null, CancellationToken cancellationToken = default) { var cuerpo = body; Guard(cuerpo, new[] { "amount", "external_reference", "name", "currency_code", "description", "metadata", "success_url", "cancel_url", "expires_at", "customer", "lock_customer_fields", "reusable", "sales_tax_type", "sales_tax_value", "tip_enabled", "tip_type", "tip_value", "tip_customer_selectable", "tip_preset_percentages", "tip_allow_custom", "tip_min_amount", "tip_max_amount", "service_fee_enabled" }, new[] { "amount", "external_reference", "name" }, "createPayment"); return RequestAsync( "POST", $"/payments", cuerpo, null, idempotencyKey ?? NewIdempotencyKey(), null, cancellationToken); } /// Retrieve a payment /// openapi operationId: getPayment public Task GetPaymentAsync(string id, CancellationToken cancellationToken = default) { return RequestAsync( "GET", $"/payments/{Uri.EscapeDataString(id)}", null, null, null, null, cancellationToken); } /// Void a transaction /// openapi operationId: voidTransaction public Task VoidTransactionAsync(string transaction_id, IDictionary? body = null, CancellationToken cancellationToken = default) { var cuerpo = body ?? new Dictionary(); Guard(cuerpo, new[] { "reason" }, Array.Empty(), "voidTransaction"); return RequestAsync( "POST", $"/payments/{Uri.EscapeDataString(transaction_id)}/void", cuerpo, null, null, null, cancellationToken); } /// Refund a transaction /// openapi operationId: refundTransaction public Task RefundTransactionAsync(string transaction_id, IDictionary body, CancellationToken cancellationToken = default) { var cuerpo = body; Guard(cuerpo, new[] { "amount", "reason" }, new[] { "amount" }, "refundTransaction"); return RequestAsync( "POST", $"/payments/{Uri.EscapeDataString(transaction_id)}/refund", cuerpo, null, null, null, cancellationToken); } /// Get receipt metadata /// openapi operationId: getReceipt public Task GetReceiptAsync(string transaction_id, CancellationToken cancellationToken = default) { return RequestAsync( "GET", $"/payments/{Uri.EscapeDataString(transaction_id)}/receipt", null, null, null, null, cancellationToken); } /// Download the receipt PDF /// /// openapi operationId: downloadReceipt. /// Returns the raw application/pdf bytes, not JSON: this route does not answer /// JSON. Hand them to File.WriteAllBytesAsync, or return them as a FileResult. /// The human-readable page is receipt_url, from GetReceiptAsync. /// public Task DownloadReceiptAsync(string transaction_id, CancellationToken cancellationToken = default) { return RequestBytesAsync( "GET", $"/payments/{Uri.EscapeDataString(transaction_id)}/receipt/download", null, null, null, null, cancellationToken); } /// List a customer's saved cards /// openapi operationId: listPaymentMethods public Task ListPaymentMethodsAsync(IDictionary? query = null, CancellationToken cancellationToken = default) { return RequestAsync( "GET", $"/payment-methods", null, query, null, null, cancellationToken); } /// Revoke a saved card /// openapi operationId: revokePaymentMethod public Task RevokePaymentMethodAsync(string payment_method_id, CancellationToken cancellationToken = default) { return RequestAsync( "DELETE", $"/payment-methods/{Uri.EscapeDataString(payment_method_id)}", null, null, null, null, cancellationToken); } /// Charge a saved card /// openapi operationId: chargeSavedCard public Task ChargeSavedCardAsync(string payment_method_id, IDictionary body, string? idempotencyKey = null, CancellationToken cancellationToken = default) { var cuerpo = body; Guard(cuerpo, new[] { "amount", "currency_code", "external_reference", "metadata" }, new[] { "amount" }, "chargeSavedCard"); return RequestAsync( "POST", $"/payment-methods/{Uri.EscapeDataString(payment_method_id)}/charge", cuerpo, null, idempotencyKey ?? NewIdempotencyKey(), null, cancellationToken); } /// Charge a saved card (alias) /// openapi operationId: tokenCharge public Task TokenChargeAsync(IDictionary body, string? idempotencyKey = null, CancellationToken cancellationToken = default) { var cuerpo = body; Guard(cuerpo, new[] { "payment_token", "amount", "currency_code", "external_reference", "metadata" }, new[] { "payment_token", "amount" }, "tokenCharge"); return RequestAsync( "POST", $"/payments/token-charge", cuerpo, null, idempotencyKey ?? NewIdempotencyKey(), null, cancellationToken); } /// Confirm an embedded-components payment /// openapi operationId: confirmEmbeddedPayment public Task ConfirmEmbeddedPaymentAsync(IDictionary body, CancellationToken cancellationToken = default) { var cuerpo = body; Guard(cuerpo, new[] { "amount", "currency_code", "payment_token", "publishable_key", "external_reference", "success_redirect_url", "failed_redirect_url", "description", "metadata" }, new[] { "amount", "currency_code", "payment_token", "publishable_key" }, "confirmEmbeddedPayment"); return RequestAsync( "POST", $"/confirm", cuerpo, null, null, "https://aura.roki.systems/api/connect/embed", cancellationToken); } /// Verify a webhook signature. /// /// rawBody must be the exact bytes received. In ASP.NET Core read it with /// await new StreamReader(Request.Body).ReadToEndAsync() and keep model binding off that /// route: re-serialising the parsed object changes key order, spacing and number formatting, /// and the HMAC will never match. /// /// Header: ROKI-Signature: t={timestamp},v1={hex} /// public static bool VerifyWebhook( string rawBody, string? signatureHeader, string signingSecret, int toleranceSeconds = 300, DateTimeOffset? now = null) => VerifyWebhook(Encoding.UTF8.GetBytes(rawBody ?? string.Empty), signatureHeader, signingSecret, toleranceSeconds, now); public static bool VerifyWebhook( byte[] rawBody, string? signatureHeader, string signingSecret, int toleranceSeconds = 300, DateTimeOffset? now = null) { if (string.IsNullOrEmpty(signatureHeader) || string.IsNullOrEmpty(signingSecret)) { return false; } var m = FirmaRe.Match(signatureHeader); if (!m.Success) { return false; } var timestamp = m.Groups[1].Value; var recibida = m.Groups[2].Value.ToLowerInvariant(); if (toleranceSeconds > 0) { var ahora = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(); if (Math.Abs(ahora - long.Parse(timestamp, CultureInfo.InvariantCulture)) > toleranceSeconds) { return false; // replay of an old, genuinely signed event } } var prefijo = Encoding.UTF8.GetBytes(timestamp + "."); var firmado = new byte[prefijo.Length + rawBody.Length]; Buffer.BlockCopy(prefijo, 0, firmado, 0, prefijo.Length); Buffer.BlockCopy(rawBody, 0, firmado, prefijo.Length, rawBody.Length); using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret)); var esperada = Convert.ToHexString(hmac.ComputeHash(firmado)).ToLowerInvariant(); // FixedTimeEquals, not ==: a byte-by-byte comparison leaks the signature through timing. return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(esperada), Encoding.UTF8.GetBytes(recibida)); } /// A key stable enough that a retry is the same payment, unique enough that two are not. public string NewIdempotencyKey() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); // ---------------------------------------------------------------- internals private void Guard(IDictionary body, string[] known, string[] required, string operation) { var faltan = required.Where(r => !body.ContainsKey(r)).ToArray(); if (faltan.Length > 0) { throw new RokiException($"{operation}: missing required field(s): {string.Join(", ", faltan)}"); } if (!_strict || known.Length == 0) { return; } foreach (var campo in body.Keys) { if (known.Contains(campo)) { continue; } var msg = $"{operation}: \"{campo}\" is not a field of this API, and ROKI would accept the request and ignore it."; if (FieldAliases.TryGetValue(campo, out var alias)) { msg += $" Coming from another gateway? {alias}"; } else { var pista = Closest(campo, known); if (pista != null) { msg += $" Did you mean \"{pista}\"?"; } } throw new RokiException(msg); } } /// The transport, with no opinion about what came back. /// /// Separated from RequestAsync because not every route answers JSON: the receipt /// download answers application/pdf, and JsonNode.Parse on those bytes threw, which used /// to surface as "Non-JSON response" on a route that was working perfectly. /// private async Task<(int Codigo, bool Ok, string ContentType, byte[] Cuerpo)> SendAsync( string method, string path, IDictionary? body, IDictionary? query, string? idempotencyKey, string? overrideBase, CancellationToken cancellationToken) { var url = (overrideBase?.TrimEnd('/') ?? _base) + path; if (query != null) { var partes = new List(); foreach (var par in query) { if (par.Value == null) { continue; } var v = Convert.ToString(par.Value, CultureInfo.InvariantCulture); if (string.IsNullOrEmpty(v)) { continue; } partes.Add(Uri.EscapeDataString(par.Key) + "=" + Uri.EscapeDataString(v)); } if (partes.Count > 0) { url += "?" + string.Join("&", partes); } } using var peticion = new HttpRequestMessage(new HttpMethod(method), url); peticion.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _secretKey); peticion.Headers.TryAddWithoutValidation("Accept", "application/json"); peticion.Headers.TryAddWithoutValidation("Accept-Language", _language); peticion.Headers.TryAddWithoutValidation("User-Agent", "roki-connect-dotnet/" + Version + "+build." + Build); if (idempotencyKey != null) { peticion.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey); } if (body != null) { peticion.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); } using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_timeout); int codigo; bool ok; string tipo; byte[] cuerpoCrudo; try { using var respuesta = await _http.SendAsync(peticion, cts.Token).ConfigureAwait(false); codigo = (int)respuesta.StatusCode; ok = respuesta.IsSuccessStatusCode; tipo = respuesta.Content.Headers.ContentType?.MediaType ?? string.Empty; cuerpoCrudo = await respuesta.Content.ReadAsByteArrayAsync().ConfigureAwait(false); } catch (Exception e) when (e is HttpRequestException || e is OperationCanceledException) { // A timeout is not a failed payment: it may exist. Retry with the SAME Idempotency-Key, // or look it up by external_reference before creating another. throw new RokiException($"Network error calling {method} {path}: {e.Message}"); } return (codigo, ok, tipo, cuerpoCrudo); } private async Task RequestAsync( string method, string path, IDictionary? body, IDictionary? query, string? idempotencyKey, string? overrideBase, CancellationToken cancellationToken) { var (codigo, ok, _, crudo) = await SendAsync( method, path, body, query, idempotencyKey, overrideBase, cancellationToken).ConfigureAwait(false); var texto = Encoding.UTF8.GetString(crudo); JsonNode? json; try { json = JsonNode.Parse(texto); } catch (JsonException) { json = null; } if (json == null) { throw new RokiException($"Non-JSON response from {method} {path} (HTTP {codigo})", codigo); } if (!ok) { throw ErrorFor(codigo, json, method, path); } // The API names the fields it ignored. Absent when the request was clean, so a quiet log is // the proof your field names are right. The text is localised by Accept-Language: surface // it, never branch on it. if (json["warnings"] is JsonArray avisos && avisos.Count > 0) { _onWarnings(avisos.Select(a => a?.ToString() ?? string.Empty).ToArray(), method, path); } return json; } /// The exact bytes of a route the contract declares is not JSON. /// /// Failures on those routes still come back as JSON - a 404 for passing the numeric id instead /// of the transaction_id UUID is the usual one - so they get decoded and reported like every /// other call. Only the success path hands back bytes. /// private async Task RequestBytesAsync( string method, string path, IDictionary? body, IDictionary? query, string? idempotencyKey, string? overrideBase, CancellationToken cancellationToken) { var (codigo, ok, _, crudo) = await SendAsync( method, path, body, query, idempotencyKey, overrideBase, cancellationToken).ConfigureAwait(false); if (ok) { return crudo; } JsonNode? json; try { json = JsonNode.Parse(Encoding.UTF8.GetString(crudo)); } catch (JsonException) { json = null; } if (json == null) { throw new RokiException( $"ROKI {method} {path} failed with {codigo} and a body that is not JSON", codigo); } throw ErrorFor(codigo, json, method, path); } private static RokiException ErrorFor(int status, JsonNode json, string method, string path) { var message = json["message"]?.ToString() ?? json["error"]?.ToString() ?? "request failed"; if (status == 422 && json["errors"] is JsonObject errores) { var detalle = errores.Select(par => { var v = par.Value is JsonArray arr ? string.Join(" ", arr.Select(x => x?.ToString())) : par.Value?.ToString(); return $"{par.Key}: {v}"; }); message += " (" + string.Join("; ", detalle) + ")"; } if (status == 404) { message += ". A 404 here usually means the identifier is of the wrong kind: void, refund and " + "receipts take the transaction_id UUID, not the numeric payment id."; } if (status == 401) { message += ". Check the key prefix: sk_test_ only works against sandbox data, " + "sk_live_ only against production."; } return new RokiException($"ROKI {method} {path} failed with {status}: {message}", status, json); } /// The closest real field name. /// /// Edit distance alone is not enough here: service_fee is 8 edits away from service_fee_enabled /// and is by far the most common mistake against this API. A shared prefix outranks distance. /// private static string? Closest(string needle, string[] candidates) { var prefijo = candidates.FirstOrDefault(c => c.StartsWith(needle, StringComparison.Ordinal) || needle.StartsWith(c, StringComparison.Ordinal)); if (prefijo != null) { return prefijo; } string? mejor = null; var mejorPuntaje = int.MaxValue; foreach (var c in candidates) { var d = Levenshtein(needle, c); if (d < mejorPuntaje) { mejorPuntaje = d; mejor = c; } } return mejorPuntaje <= Math.Max(2, needle.Length / 3) ? mejor : null; } private static int Levenshtein(string a, string b) { var prev = new int[b.Length + 1]; for (var i = 0; i <= b.Length; i++) { prev[i] = i; } for (var i = 1; i <= a.Length; i++) { var ultimo = prev[0]; prev[0] = i; for (var jj = 1; jj <= b.Length; jj++) { var tmp = prev[jj]; prev[jj] = Math.Min(Math.Min(prev[jj] + 1, prev[jj - 1] + 1), ultimo + (a[i - 1] == b[jj - 1] ? 0 : 1)); ultimo = tmp; } } return prev[b.Length]; } }