Unofficial API client implementation for the Pingen v2 API. See API Doc. (used version 2.0.0)
With a special thanks to Pingen GmbH for the handy Online Postal Service for sending letters digitally. See Pingen website.
- PingenApiNet: Client service to interact with the Pingen v2 API
- PingenApiNet.Abstractions: Models, Views and Enums used for the API
- PingenApiNet.AspNetCore: Dependency injection in ASP.NET Core
Get a file path and upload file.
var resultFilePath = await _pingenApiClient.Files.GetPath();
if (!resultFilePath.IsSuccess)
return $"Failed to get file upload path from pingen: {JsonConvert.SerializeObject(resultFilePath.ApiError)}";
if (resultFilePath.Data?.Data is null)
return "Failed to get file upload path from pingen, request was OK but return null";
var uploadResult = await _pingenApiClient.Files.UploadFile(resultFilePath.Data.Data, contentStream);
if (!uploadResult.IsSuccess)
return $"Failed to upload file to pingen storage: {uploadResult.StatusCode} {uploadResult.ReasonPhrase}";Create the letter.
var resultCreateLetter = await _pingenApiClient.Letters.Create(new()
{
Attributes = new()
{
FileOriginalName = filename,
FileUrl = resultFilePath.Data.Data.Attributes.Url,
FileUrlSignature = resultFilePath.Data.Data.Attributes.UrlSignature,
AddressPosition = LetterAddressPosition.left,
AutoSend = false,
DeliveryProduct = LetterCreateDeliveryProduct.Cheap, // A more specific product can be used later at Letters/Send endpoint
PrintMode = LetterPrintMode.simplex,
PrintSpectrum = LetterPrintSpectrum.color,
MetaData = new() // This must only be set when "postag_registered" or "postag_registered" product used. Otherwise the API can fail at address validation when Zip code has more than 4 characters.
{
Recipient = new()
{
Name = recipientName,
Street = recipientAddress.Street,
Number = recipientAddress.HouseNumber,
Zip = recipientAddress.Postcode,
City = recipientAddress.City,
Country = recipientAddress.Country
},
Sender = new()
{
Name = senderName,
//...
Country = senderAddress.Country
}
}
},
Type = PingenApiDataType.letters,
Relationships = LetterCreateRelationships.Create("1234567890") // Optionally add a preset via relationships
}, $"Letters.Create.{outgoing.Id}"); // Optionally, idempotency key
if (!resultCreateLetter.IsSuccess)
return $"Failed create letter on pingen: {JsonConvert.SerializeObject(resultCreateLetter.ApiError)}";
if (resultCreateLetter.Data?.Data is null)
return "Failed create letter on pingen, request was OK but return null";Check the letter state and send it.
// NOTE: Consider using a loop with delay to poll the letter state (Pingen needs some seconds to validate the letter)
var resultGetLetter = await _pingenApiClient.Letters.Get(letter.Id);
if (resultGetLetter.IsSuccess
&& resultGetLetter.Data?.Data.Attributes.Status == LetterStates.Valid)
{
var resultSendLetter = await _pingenApiClient.Letters.Send(new()
{
Type = PingenApiDataType.letters,
Attributes = new()
{
DeliveryProduct = LetterSendDeliveryProduct.PostAgA
PrintMode = LetterPrintMode.simplex,
PrintSpectrum = LetterPrintSpectrum.color,
MetaData = // Use from sample above at Letters.Create
},
Id = resultGetLetter.Data!.Data.Id
});
if (!resultSendLetter.IsSuccess)
return $"Failed to send letter on pingen: {JsonConvert.SerializeObject(resultSendLetter.ApiError)}";
}Get letter and download file.
var resultFileLocation = await _pingenApiClient.Letters.GetFileLocation(letterId);
if (!resultFileLocation.IsSuccess)
return new(null, $"Failed to get file location from letter on pingen: {JsonConvert.SerializeObject(resultFileLocation.ApiError)}");
if (resultFileLocation.Location is null)
return new(null, "Failed to get file location from letter on pingen, request was OK but location null");
try
{
var memoryStrem = await _pingenApiClient.Letters.DownloadFileContent(resultFileLocation.Location);
}
catch (PingenFileDownloadException e)
{
return $"Failed to download file from pingen: {e.ErrorCode}";
}Get all delivery products with auto paging.
await foreach (var page in _pingenApiClient.Distributions.GetDeliveryProductsPageResultsAsync(apiPagingRequest))
{
deliveryProducts.AddRange(page);
}This is barely tested but should work as follows:
var apiPagingRequest = new ApiPagingRequest
{
Sorting = new Dictionary<string, CollectionSortDirection>
{
[PingenAttributesPropertyHelper<Letter>.GetJsonPropertyName(letter => letter.CreatedAt)] = CollectionSortDirection.DESC
},
Filtering = new(
CollectionFilterOperator.And,
new KeyValuePair<string, object>[]
{
new(CollectionFilterOperator.Or, new KeyValuePair<string, object>[]
{
new(PingenAttributesPropertyHelper<Letter>.GetJsonPropertyName(letter => letter.Country), "CH"),
new(PingenAttributesPropertyHelper<Letter>.GetJsonPropertyName(letter => letter.Country), "LI")
}),
new(PingenAttributesPropertyHelper<Letter>.GetJsonPropertyName(letter => letter.Status), "valid")
})
};Register pingen services. You can inject the client by interface IPingenApiClient.
services.AddPingenServices(new PingenConfiguration
{
BaseUri = "https://api-staging.pingen.com",
IdentityUri = "https://identity-staging.pingen.com",
ClientId = /****/,
ClientSecret = /****/,
DefaultOrganisationId = /****/
});Validate webhook and extract data.
[NonAction]
private async Task<(WebhookEventData WebhookEventData, Data<Organisation> OrganisationData, Data<Letter> LetterData, Data<LetterEvent> EventData)> ValidateAndGetWebhookEvent()
{
// Enable request buffering for multiple body stream reads
Request.EnableBuffering();
// Ensure signature header received
if (!Request.Headers.TryGetValue("Signature", out var signature))
throw new InvalidOperationException("Missing 'Signature' header");
// Validate webhook body and get data
var (webhookEventData, organisationData, letterData, eventData) = await PingenWebhookHelper.ValidateWebhookAndGetData(signingKey, /*your signature*/, Request.Body);
if (webhookEventData is null)
throw new InvalidOperationException("Failed to parse WebhookEventData from body, resulted as NULL");
return (webhookEventData, organisationData, letterData, eventData);
}Request related resources to be sideloaded in the response using the Include property on ApiRequest / ApiPagingRequest. Each resource type has a corresponding static *Includes class with discoverable constants.
// Fetch letters with organisation and events included
var result = await _pingenApiClient.Letters.GetPage(new ApiPagingRequest
{
Include = [LetterIncludes.Organisation, LetterIncludes.Events]
});Available include helpers:
| Class | Constants |
|---|---|
LetterIncludes |
Organisation, Events |
BatchIncludes |
Organisation, Events |
OrganisationIncludes |
Associations |
UserIncludes |
Associations, Notifications |
UserAssociationIncludes |
Organisation |
LetterEventIncludes |
Letter |
WebhookIncludes |
Organisation |
WebhookEventIncludes |
Organisation, Letter, Event |
Reduce response payload size by requesting only the specific fields you need using the SparseFieldsets property on ApiRequest / ApiPagingRequest. Each entry maps a PingenApiDataType to the list of field names to include. Use the static *Fields helper classes to discover available fields per resource type.
// Fetch a single page of letters, returning only status and created_at
var result = await _pingenApiClient.Letters.GetPage(new ApiPagingRequest
{
SparseFieldsets =
[
new(PingenApiDataType.letters, [LetterFields.Status, LetterFields.CreatedAt])
]
});Combine sparse fieldsets with the include parameter to also control fields on sideloaded relationships:
var result = await _pingenApiClient.Letters.GetPage(new ApiPagingRequest
{
SparseFieldsets =
[
new(PingenApiDataType.letters, [LetterFields.Status, LetterFields.Address, LetterFields.CreatedAt]),
new(PingenApiDataType.organisations, [OrganisationFields.Name])
],
Include = [LetterIncludes.Organisation]
});Available field helpers:
| Class | Resource type | Key constants |
|---|---|---|
LetterFields |
PingenApiDataType.letters |
Status, FileOriginalName, FilePages, Address, AddressPosition, Country, DeliveryProduct, PrintMode, PrintSpectrum, PriceCurrency, PriceValue, PaperTypes, Fonts, TrackingNumber, SubmittedAt, CreatedAt, UpdatedAt |
BatchFields |
PingenApiDataType.batches |
Name, Icon, Status, FileOriginalName, LetterCount, AddressPosition, PrintMode, PrintSpectrum, PriceCurrency, PriceValue, SubmittedAt, CreatedAt, UpdatedAt |
OrganisationFields |
PingenApiDataType.organisations |
Name, Status, Plan, BillingMode, BillingCurrency, BillingBalance, DefaultCountry, DefaultAddressPosition, DataRetentionAddresses, DataRetentionPdf, Color, CreatedAt, UpdatedAt |
UserFields |
PingenApiDataType.users |
Email, FirstName, LastName, Status, Language, CreatedAt, UpdatedAt |
UserAssociationFields |
PingenApiDataType.associations |
Role, Status, CreatedAt, UpdatedAt |
WebhookFields |
PingenApiDataType.webhooks |
EventCategory, Url, SigningKey |
LetterEventFields |
PingenApiDataType.letters_events |
Code, Name, Producer, Location, HasImage, Data, EmittedAt, CreatedAt, UpdatedAt |
WebhookEventFields |
PingenApiDataType.webhook_issues / webhook_sent / webhook_undeliverable |
Reason, Url, CreatedAt |
DeliveryProductFields |
PingenApiDataType.delivery_products |
Countries, Name, FullName, DeliveryTimeDays, Features, PriceCurrency, PriceStartingFrom |
LetterPriceFields |
PingenApiDataType.letter_price_calculator |
Currency, Price |