Originally published at into an ASP.NET Core application — from the first curl command to an idiomatic typed
HtpbeClientbuilt onIHttpClientFactory, withSystem.Text.Jsonrecord DTOs, configuration-bound options for the API key, polling with backoff, error handling that distinguishes a configuration failure from a transient one, and a small bank-statement gate that decides accept / reject / review. The patterns target .NET 8 (the current LTS) and use minimal APIs, but everything maps cleanly to a controller-based project. Treat the code as a reference architecture — it runs the real request flow against the documented error codes, but you should adapt and harden it for your own traffic profile and threat model. (If you want the conceptual overview first, start with , , guides.)
TL;DR
- Two API calls, three verdicts:
POST /analyzereturns a top-levelid, thenGET /result/{id}returns the flat verdict object whosestatusis one ofintact,modified, orinconclusive.
- The minimum integration is a typed
HttpClientand two awaited calls. No dependency beyond the framework andSystem.Text.Json.
- Production shape: an
HtpbeClienttyped client registered withIHttpClientFactory, record DTOs withsnake_casenaming, a custom exception carrying the status code, and a Polly retry policy that backs off on 5xx and 429 only.
- A
DocumentGatethat maps the three verdicts to anAccept/Reject/Reviewdecision for documents that claim institutional origin.
- This is structural PDF tamper and forgery detection — not KYC, not OCR, not AI-text detection. It complements an identity stack; it does not replace one.
Prerequisites
- .NET 8 SDK (records,
requiredmembers,IHttpClientFactory, minimal APIs)
- An HTPBE API key (Dashboard → copy key)
- Optionally
Microsoft.Extensions.Http.Pollyfor the resilience policy in Step 6 (the frameworkHttpClientworks without it)
Step 1: Test the API with curl
Before writing any C#, confirm your key works. The API uses a two-step flow:
POST /analyzesubmits a PDF URL and returns a check id, thenGET /result/{id}retrieves the full verdict. (For a language-agnostic overview of what the API detects, see . These ids are part of the public contract and never change once shipped. The API does not return a numeric risk score — the verdict plus the named markers are the whole signal, by design, so there is no threshold to tune on your side.
A small enum keeps the rest of your codebase from comparing against bare string literals:
CODEnamespace Htpbe;
public enum Verdict { Intact, Modified, Inconclusive }
public static class VerdictParser
{
public static Verdict Parse(string status) => status switch
{
"intact" => Verdict.Intact,
"modified" => Verdict.Modified,
"inconclusive" => Verdict.Inconclusive,
_ => throw new ArgumentOutOfRangeException(
nameof(status), status, "unknown status from HTPBE"),
};
}
Step 4: A Typed Exception
A 401 means your key is wrong; a 402 means the credit pool is dry; a 500 is transient. Both the retry layer and your business logic need to branch on the status code, so wrap every non-success response in a typed exception that carries it.
CODEnamespace Htpbe;
public sealed class HtpbeApiException : Exception
{
public int StatusCode { get; }
public string Code { get; } // machine-readable code from the JSON body
public int? RetryAfterSeconds { get; } // parsed from Retry-After on 429; null if absent
public HtpbeApiException(int statusCode, string code, string message, int? retryAfterSeconds = null)
: base($"htpbe: {statusCode} {code}: {message}")
{
StatusCode = statusCode;
Code = code;
RetryAfterSeconds = retryAfterSeconds;
}
// Only 5xx and 429 are transient. Every other 4xx is permanent —
// retrying it burns latency and, for 402, can never succeed until
// the account is topped up.
public bool Retryable => StatusCode >= 500 || StatusCode == 429;
}
Step 5: The Typed HttpClient
Here is the complete client. It is a typed
HttpClient— registered withIHttpClientFactoryin Step 6 — so the factory owns the connection pool and theAuthorizationheader is set once at registration. The client exposes one public method,VerifyAsync, that runs both steps of the flow; a privateParseErrorAsyncconverts every non-success response into anHtpbeApiException, reading the JSON error body and theRetry-Afterheader in one place.
CODEusing System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace Htpbe;
public sealed class HtpbeClient
{
private readonly HttpClient _http;
private readonly int _maxPollAttempts;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
};
public HtpbeClient(HttpClient http, IOptions<HtpbeOptions> options)
{
_http = http;
_maxPollAttempts = options.Value.MaxResultPollAttempts;
}
/// <summary>
/// Submits a PDF URL and returns the full verdict. The two steps are kept
/// separate on purpose: POST /analyze is the billable, job-creating call,
/// GET /result/{id} is a free read. The resilience policy (Step 6) wraps
/// the whole client, but only transient failures are retried — a permanent
/// 402 short-circuits immediately.
/// </summary>
public async Task<AnalysisResult> VerifyAsync(
string pdfUrl, string? originalFilename = null, CancellationToken ct = default)
{
var id = await SubmitAnalysisAsync(pdfUrl, originalFilename, ct);
return await GetResultAsync(id, ct);
}
private async Task<string> SubmitAnalysisAsync(
string pdfUrl, string? originalFilename, CancellationToken ct)
{
// The API accepts a JSON body with the PDF URL; original_filename is optional.
var body = originalFilename is null
? new Dictionary<string, string> { ["url"] = pdfUrl }
: new Dictionary<string, string> { ["url"] = pdfUrl, ["original_filename"] = originalFilename };
using var response = await _http.PostAsJsonAsync("analyze", body, JsonOptions, ct);
if (!response.IsSuccessStatusCode)
throw await ParseErrorAsync(response, ct);
var payload = await response.Content.ReadFromJsonAsync<AnalyzeResponse>(JsonOptions, ct);
if (payload is null || string.IsNullOrEmpty(payload.Id))
throw new HtpbeApiException(502, "BAD_RESPONSE", "analyze response missing id");
return payload.Id;
}
private async Task<AnalysisResult> GetResultAsync(string id, CancellationToken ct)
{
// POST /analyze runs the analysis synchronously, so the result is normally
// ready on the first GET. The bounded poll below is defensive: it tolerates
// a brief replication lag and re-reads on a transient 404 before giving up.
HtpbeApiException? lastError = null;
for (var attempt = 1; attempt <= _maxPollAttempts; attempt++)
{
using var response = await _http.GetAsync($"result/{id}", ct);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<AnalysisResult>(JsonOptions, ct);
return result ?? throw new HtpbeApiException(502, "BAD_RESPONSE", "empty result body");
}
lastError = await ParseErrorAsync(response, ct);
// Only a 404 is worth re-reading (the row may not be visible yet).
// Every other error is terminal — surface it without burning attempts.
if (response.StatusCode != HttpStatusCode.NotFound)
throw lastError;
await Task.Delay(TimeSpan.FromMilliseconds(300 * attempt), ct);
}
throw lastError ?? new HtpbeApiException(504, "RESULT_TIMEOUT", "result not ready after polling");
}
private static async Task<HtpbeApiException> ParseErrorAsync(
HttpResponseMessage response, CancellationToken ct)
{
var statusCode = (int)response.StatusCode;
var code = "UNKNOWN";
var message = response.ReasonPhrase ?? statusCode.ToString();
try
{
var error = await response.Content.ReadFromJsonAsync<ErrorBody>(JsonOptions, ct);
if (error is not null)
{
code = error.Code ?? code;
message = error.Error ?? message;
}
}
catch (JsonException)
{
// body was not JSON — keep the status-derived defaults
}
message = statusCode switch
{
401 => "invalid API key — check Htpbe:ApiKey",
402 => "no credits available for this key — top up or subscribe",
403 => "test key sent to a live URL, or vice versa",
413 => "PDF exceeds the 10 MB size limit",
422 => "the URL did not return a valid PDF file",
_ => message,
};
int? retryAfter = null;
if (statusCode == 429 && response.Headers.RetryAfter is { } ra)
retryAfter = ParseRetryAfter(ra);
return new HtpbeApiException(statusCode, code, message, retryAfter);
}
// Handles both the delta-seconds form and the HTTP-date form,
// clamped to [1, 600]. Returns null when neither is present.
private static int? ParseRetryAfter(System.Net.Http.Headers.RetryConditionHeaderValue ra)
{
if (ra.Delta is { } delta)
return Math.Clamp((int)delta.TotalSeconds, 1, 600);
if (ra.Date is { } date)
return Math.Clamp((int)(date - DateTimeOffset.UtcNow).TotalSeconds, 1, 600);
return null;
}
private sealed record ErrorBody
{
public string? Error { get; init; }
public string? Code { get; init; }
}
}
Two status codes deserve explicit handling in your own code:
402(Payment Required) — the key has no credit source left. Credits are universal: a subscription’s monthly quota, a one-time top-up batch, and the welcome credits all draw from one pool. A 402 means all three are exhausted (or there is no active plan on a live key).HtpbeApiException.Retryablereturnsfalsefor it — surface it to your billing path rather than retrying, because retrying fails identically until the account is topped up at . For documents that claim institutional origin, treatinconclusivewith the same caution asmodified: do not accept automatically, route to a human reviewer. Inverting that policy — treatinginconclusiveas a pass — is the single most common integration mistake, because it hands an automatic accept to exactly the consumer-software-built documents a bank statement should never be.
Step 8: Giving the API a Reachable URL
The API does not accept file uploads — it downloads the PDF from a URL you supply, so the file must be publicly reachable for the few seconds the analysis takes. The cleanest pattern is a short-lived presigned URL from your object store: you never expose the bucket, the link expires in minutes, and passing
originalFilenamekeeps the audit trail readable instead of showing an opaque storage key.
CODE// Store the upload privately, mint a 5-minute presigned GET URL, verify.
var key = $"incoming/{Guid.NewGuid()}.pdf";
await s3.PutObjectAsync(new PutObjectRequest
{
BucketName = bucket,
Key = key,
InputStream = pdfStream,
ContentType = "application/pdf",
}, ct);
var presignedUrl = await s3.GetPreSignedURLAsync(new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Expires = DateTime.UtcNow.AddMinutes(5),
Verb = HttpVerb.GET,
});
var result = await client.VerifyAsync(presignedUrl, originalFilename, ct);
The same pattern works with Azure Blob Storage (a SAS token via
BlobClient.GenerateSasUri), Google Cloud Storage (UrlSigner), or Cloudflare R2 (S3-compatible — reuse the AWS SDK with the R2 endpoint). One security note: the API fetches whatever URL you give it, so if a URL ever comes from untrusted input (a user-pasted link, a webhook payload), validate that it resolves to a public host first — rejectlocalhost,169.254.169.254(cloud metadata), and the RFC 1918 private ranges to close the SSRF surface. When you mint the URL yourself from a private bucket the risk is minimal, but the validation belongs in the request flow either way.
Step 9: Testing Without Burning Quota
Every plan includes a test API key (prefix
htpbe_test_) that accepts only mock URLs of the formhttps://api.htpbe.tech/v1/test/{filename}.pdfand returns deterministic responses — like Stripe test cards, with no quota cost. Point an integration test at these fixtures to cover every branch of the gate. WithWebApplicationFactory<Program>you exercise the realHtpbeClient, configured with the test key:
CODEusing Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
public sealed class HtpbeClientTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HtpbeClient _client;
public HtpbeClientTests(WebApplicationFactory<Program> factory)
{
var configured = factory.WithWebHostBuilder(builder =>
builder.UseSetting("Htpbe:ApiKey",
Environment.GetEnvironmentVariable("HTPBE_TEST_API_KEY")!));
_client = configured.Services.GetRequiredService<HtpbeClient>();
}
[Fact]
public async Task CleanDocumentReturnsIntact()
{
var r = await _client.VerifyAsync("https://api.htpbe.tech/v1/test/clean.pdf");
Assert.Equal("intact", r.Status);
Assert.Empty(r.ModificationMarkers);
Assert.Equal(Decision.Accept, DocumentGate.ForInstitutional(r));
}
[Fact]
public async Task SignatureRemovedIsRejected()
{
var r = await _client.VerifyAsync("https://api.htpbe.tech/v1/test/signature-removed.pdf");
Assert.Equal("modified", r.Status);
Assert.True(r.SignatureRemoved);
Assert.Equal(Decision.Reject, DocumentGate.ForInstitutional(r));
}
[Fact]
public async Task InconclusiveIsRoutedToReview()
{
var r = await _client.VerifyAsync("https://api.htpbe.tech/v1/test/inconclusive.pdf");
Assert.Equal("inconclusive", r.Status);
Assert.NotNull(r.StatusReason);
Assert.Equal(Decision.Review, DocumentGate.ForInstitutional(r));
}
}
Useful fixtures:
clean.pdf→intact,signature-removed.pdf→modified,dates-mismatch.pdf→modified, andinconclusive.pdf→inconclusive. For pure unit tests of the endpoint and gate without any network, inject a fakeHttpMessageHandlerinto theHtpbeClientand return canned JSON, or testDocumentGate.ForInstitutionaldirectly against a hand-builtAnalysisResult. Keep test and live keys in separate configuration sources and never commit either.
For audit dashboards,
GET /api/v1/checksreturns a paginated list of every result for your key — filter bystatusandlimit(/checks?status=modified&limit=50, sameAuthorizationheader). When you reach your monthly quota, further requests return402 PAYMENT_REQUIREDuntil it resets — add a one-time credit pack or move to a higher tier to keep going, and handle the 402 so a quota boundary never silently drops a check.
What the Verdicts Mean
The whole signal is three verdicts and a list of named markers. Encoding them correctly in your
DocumentGatematters more than any other choice in the integration:
intact— no post-creation modification was detected and the origin looks institutional. Safe to accept on the automated path.
modified— forensic evidence of an edit after the document was created. Themodification_markersarray names the signal:HTPBE_DATES_DISAGREEfor inconsistent internal timestamps,HTPBE_SIGNATURE_REMOVEDfor a stripped digital signature,HTPBE_POST_SIGNATURE_EDITfor changes made after signing,HTPBE_MULTIPLE_REVISION_LAYERSfor a document saved repeatedly after creation. Reject, or route to fraud review.
inconclusive— the document was built with consumer software, an online editor, an HTML renderer, or a scanner, so there is no institutional “original” to verify integrity against. This is not a failure and not a clean pass — it is a routing signal. For a document that should have come from an institution (a bank statement, a payslip),inconclusivemeans it did not, which is exactly whyDocumentGate.ForInstitutionalroutes it to a human reviewer.
What This Does Not Catch
Structural analysis has honest limits, and an ASP.NET Core service making automated decisions should encode them rather than overstate the verdict:
Content fabricated in one pass. If someone opens Word, types a false salary, and exports once, the file was never modified after creation — it is structurally consistent. The fraud happened at authorship, not at the byte level. This is exactly why a payslip from a consumer tool tends to returninconclusiverather thanintact: the analysis cannot vouch for a document anyone could have produced from scratch.
Born-synthetic forgeries. A fake document generated programmatically with a valid-looking account number and a real logo — never derived from a genuine original — has no post-creation edit to detect. Catching that is a content-verification problem (does this account number exist, does this employer match payroll records), a different product category from structural tamper detection.
Documents rebuilt from scratch in the original’s software. A determined attacker who recreates a document in the same institutional tool and matches the metadata leaves few structural signals. This is rare and high-effort, but possible.
Encrypted or password-protected PDFs. The service cannot parse a file it cannot open; remove the password before submitting.
These limits are why structural tamper detection works as one layer in a fraud-detection stack, not the whole stack. Pair the structural verdict with domain checks — amount validation, account-number lookups, sender authentication, and your KYC or OCR provider — for a layered defense. The structural layer answers a question identity verification cannot: was this file edited after it was issued? See — new accounts get five checks to try, then pay-per-check credits or a subscription (see documents every response field, error code, and the marker dictionary the .NET client branches on.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
🔧 Programmierung 🕛 vor 1 Monat 23 Min Lesezeit
PDF Tamper Detection API for C#: ASP.NET Core Integration Guide
📑 Inhaltsübersicht
- ▸ TL;DR
- ▸ Prerequisites
- ▸ Step 1: Test the API with curl
- ▸ Step 2: Bind Configuration, Not Constants
- ▸ Step 3: The Result DTO
- ▸ Step 4: A Typed Exception
- ▸ Step 5: The Typed HttpClient
- ▸ Step 6: Register the Client and Retry on Transient Failures Only
- ▸ Step 7: The Bank-Statement Gate
- ▸ Step 8: Giving the API a Reachable URL
- ▸ Step 9: Testing Without Burning Quota
- ▸ What the Verdicts Mean
- ▸ What This Does Not Catch
- ▸ Decisions Before You Ship
Wie bewertest du diesen Beitrag?
1 Klick Feedback Teilen mit Netzwerk & Team:
Hat Ihnen dieser Tipp / Anleitung geholfen?
Community-Analysen & Experten-Meinungen 0
Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf „ Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum 🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Tipp: Mit Pfeiltasten [ ← ] und [ → ] blättern
SOCIAL SHARE CARD GENERATOR