Support

DTO Flow help center

Practical guidance for launching partner file exchanges, helping partners succeed, connecting systems, and getting support when you need it.

Integration

Automation

Connect trusted systems when recurring files should be uploaded, checked, or collected automatically without adding manual steps for your team.

Explore API file upload for partner feedsReview security for connected workflows

Partitioned feeds and Subfeeds: setup, access, and troubleshooting

Access

Private Access Tokens

Private access tokens are scoped credentials for scripts, jobs, and connected partner systems. Create a token only for the feed and action the automation needs, store the raw secret right away, and revoke old tokens when the owner, server, or integration changes.

  • Use one token per script, scheduled job, partner system, or deployment environment.
  • Grant the smallest useful scopes, such as read-only period checks, upload, or download.
  • Store the secret in a password manager, CI/CD secret, vault, or scheduler secret store.
  • Rotate tokens on a planned schedule and immediately after access ownership changes.
  • Review feed logs when investigating which token checked, uploaded, or downloaded files.

Treat private access tokens like passwords. DTO Flow shows the raw secret once so it can be stored securely by the system that will use it.

Discover a destination and follow an exact submission

GetConnectionAsync returns safe organization and token identity, expiry, partner binding, organization permissions, feed access mode and capabilities. It never returns credentials. ListAccessibleFeedsAsync returns feed IDs, slugs, names, direction, status, partitioning mode and effective permissions. Pages start at 1, contain up to 1,000 items and include HasNextPage; an empty catalog is valid.

ListSubfeedsAsync returns currently accessible destinations. Organization tokens can discover subfeeds without recipients; partner tokens see only their current assignment. Read the manifest for expected files, schedule, partitioning mode, MaxFileCount and MaxFileSizeMegaBytes (MiB). Discovery and manifests accept any applicable Upload, ViewReports, DownloadOutputs or ManageSubfeeds grant.

ListScheduledPeriodsAsync and the corresponding subfeed-key and partner-key methods return business labels, keys, boundaries, deadlines, grace information and scheduleRevision. Select a returned period; clients do not calculate deadlines. Optional from and to are paired dates: inclusive from and exclusive to against period starts in the feed time zone. Omit both for 11 past periods, the current period and 1 future period. Requests may enumerate at most 60 past and 24 future periods; oversized ranges fail without truncation. Ad hoc feeds return an empty catalog and continue using existing uploads.

Period discovery contains no submission history or validation data. UploadAllowed and UploadBlockedReason reflect current permissions, lifecycle, activation windows and organization policy. Creation still checks current configuration and file-specific limits. Use CreateUploadSessionByPeriodAsync or the corresponding subfeed method with the returned period key, scheduleRevision, all files and upload options. Upload the returned targets and call the completion method.

Preserve the selected period and scheduleRevision with the report version and Idempotency-Key. New work using an old revision returns HTTP 409 PeriodSelectionStale: refresh the catalog and select again. A matching existing key recovers the original submission first, even after a schedule change, keeping its saved period and deadline. Changing a selection with the same key returns IdempotencyKeyConflict. Do not silently refresh the selection while recovering an existing upload.

GetSubmissionAsync(submissionId) needs only the ID from an upload session or webhook and requires ViewReports. It returns saved period and deadline, actual partner attribution, summary statuses, file metadata, acceptance and CurrentAcceptedSubmissionId. It does not issue download links or expose validation issues. Review detailed validation in the GUI. Failed corrections do not displace earlier accepted submissions.

GetOriginalDownloadAsync(submissionId, submittedFileId) requires DownloadOutputs and issues an audited, temporary link to that retained original, including failed, rejected, archived and superseded submissions. DownloadOriginalFileAsync streams it. Refresh expired links using the same IDs; missing or deleted originals return 404 and another submission is never substituted. Deadline methods select the current accepted submission. Shared period status retains latest-attempt fields and reports AcceptedFilesAvailable from the current accepted submission.

AutomationSubfeedIdentity identifies a subfeed through SubfeedId, SubfeedKey and SubfeedStatus. A subfeed can have several recipients or none. AutomationSubmission.PartnerId and PartnerKey identify the partner attributed to a submission and can be null for organization uploads. Partner access follows current grants and assignments.

Code sample

Discover → select → upload → retrieve the same submission

This .NET example performs one attempt with all input files in one session. Choose the period key from discovery. Your integration saves the selection, key, source bytes, submission ID and completed stage for recovery. Follow the upload recovery and concurrency guidance when running this example.

DiscoveryExample.cs
using DtoFlow.Integration;
using DtoFlow.Integration.Models;

namespace DtoFlow.IntegrationExample;

public static class DiscoveryExample
{
    // periodKey is a choice from the returned catalog, for example a selection saved by your UI.
    // Keep the reportVersionKey, selection and file bytes for retries of this report version.
    public static async Task<AutomationSubmission> RunAsync(
        DtoFlowAutomationClient client, string feedSlug, string subfeedKey, string periodKey,
        string reportVersionKey, IReadOnlyList<(string Path, string ContentType)> inputs,
        string downloadDirectory, CancellationToken cancellationToken)
    {
        var connection = await client.GetConnectionAsync(cancellationToken);
        if (!connection.Capabilities.Contains("PeriodSelection"))
            throw new InvalidOperationException("This connection does not support period selection.");
        AutomationAccessibleFeed? feed = null;
        for (var page = 1; ; page++)
        {
            var feeds = await client.ListAccessibleFeedsAsync(page, cancellationToken);
            feed = feeds.Items.SingleOrDefault(item => item.Slug == feedSlug);
            if (feed is not null || !feeds.HasNextPage) break;
        }
        if (feed is null) throw new InvalidOperationException("The token cannot access the configured feed.");

        AutomationSubfeedResourceResponse? destination = null;
        for (var page = 1; ; page++)
        {
            var subfeeds = await client.ListSubfeedsAsync(feed.Slug, page, cancellationToken);
            destination = subfeeds.Items.SingleOrDefault(item => item.SubfeedKey == subfeedKey);
            if (destination is not null || !subfeeds.HasNextPage) break;
        }
        if (destination is null) throw new InvalidOperationException("The upload destination is unavailable.");
        var manifest = await client.GetSubfeedManifestBySubfeedKeyAsync(feed.Slug, destination.SubfeedKey, cancellationToken);
        var periods = await client.ListSubfeedScheduledPeriodsBySubfeedKeyAsync(feed.Slug, destination.SubfeedKey,
            cancellationToken: cancellationToken);
        var period = periods.Single(item => item.PeriodKey == periodKey);
        if (!period.UploadAllowed) throw new InvalidOperationException(period.UploadBlockedReason);
        if (inputs.Count > manifest.Feed.UploadLimits.MaxFileCount)
            throw new InvalidOperationException("Too many files for one submission.");
        var selection = new AutomationPeriodSelection(period.PeriodKey, period.ScheduleRevision);
        var files = inputs.Select(input => new CreateAutomationUploadFileRequest(null,
            System.IO.Path.GetFileName(input.Path), input.ContentType, new FileInfo(input.Path).Length)).ToArray();
        var session = await client.CreateSubfeedUploadSessionByPeriodAndSubfeedKeyAsync(feed.Slug, destination.SubfeedKey,
            selection, files, new AutomationUploadRequestOptions { IdempotencyKey = reportVersionKey }, cancellationToken);
        // Save session.SubmissionId before uploading. A recovered session contains only missing targets.
        foreach (var target in session.Files)
        {
            var input = inputs.Single(input => System.IO.Path.GetFileName(input.Path) == target.OriginalFileName);
            await using var source = File.OpenRead(input.Path);
            await client.UploadFileAsync(target, source, cancellationToken);
        }
        await client.CompleteSubfeedUploadBySubfeedKeyAsync(feed.Slug, destination.SubfeedKey, session.SubmissionId, cancellationToken);
        AutomationSubmission submission;
        for (var attempt = 0; ; attempt++)
        {
            submission = await client.GetSubmissionAsync(session.SubmissionId, cancellationToken);
            if (submission.Accepted || submission.Status is "ValidationFailed" or "Rejected" or "Archived") break;
            if (attempt == 29) throw new TimeoutException($"Check submission {session.SubmissionId} again later.");
            await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
        }
        Directory.CreateDirectory(downloadDirectory);
        foreach (var file in submission.Files)
        {
            var link = await client.GetOriginalDownloadAsync(submission.SubmissionId, file.SubmittedFileId, cancellationToken);
            // A local name based on the file ID avoids trusting remote names as filesystem paths.
            var destinationPath = System.IO.Path.Combine(downloadDirectory, $"{file.SubmittedFileId:N}.original");
            await SaveDownloadAsync(destinationPath,
                (output, ct) => client.DownloadOriginalFileAsync(link, output, ct), cancellationToken);
        }
        return submission; // Inspect validation details in the GUI when Status is ValidationFailed.
    }

    static async Task SaveDownloadAsync(string destinationPath,
        Func<Stream, CancellationToken, Task> download, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(download);
        cancellationToken.ThrowIfCancellationRequested();
        var fullPath = Path.GetFullPath(destinationPath);
        if (File.Exists(fullPath)) throw new IOException("The download destination already exists.");
        var temporaryPath = Path.Combine(Path.GetDirectoryName(fullPath)!, $".dtoflow-{Guid.NewGuid():N}.partial");
        var output = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
        try
        {
            await using (output)
            {
                await download(output, cancellationToken);
            }
            cancellationToken.ThrowIfCancellationRequested();
            File.Move(temporaryPath, fullPath); // Same directory; never replace an existing destination.
        }
        finally
        {
            File.Delete(temporaryPath); // Only this attempt's file; a completed move leaves nothing here.
        }
    }
}

Resumable monthly report uploads

When uploading multiple reports, keep at most four uploads in progress from session creation through completion. Create a session only after a worker slot is available, stream its file, and save the result before moving on. Check period status first and skip Subfeeds with a current accepted submission.

The server defaults to eight concurrent upload API operations per organization, 32 per API instance, and one session-creation request per second with a burst of eight. Completion does not consume the creation-rate allowance. HTTP 429 with code AutomationUploadThrottled includes Retry-After: pause new work across the organization's workers for at least that delay.

Send an optional Idempotency-Key for one report version: one nonblank printable ASCII value of at most 128 characters. Preserve case, spaces, request metadata and the source bytes when retrying. The same key reuses the same submission in its feed/Subfeed scope, including after token rotation. Changed metadata returns 409 IdempotencyKeyConflict. A deliberate correction needs a new key after any unfinished session is resolved. The guarantee lasts while the submission exists.

To recognize identical submissions across different run keys, send contentSha256 (64 hexadecimal SHA-256 characters) for every file together with Idempotency-Key. Matching uses the entire file set, including names and feed-file definitions, within the same organization, feed, Subfeed and scheduled period; shared feeds also keep Partners separate. The same set returns its existing submission even while processing or after failed validation. Adding, removing or changing a file creates a different set; another Subfeed or period remains independent. Files go directly from your integration to blob storage. DTO Flow uses the caller-declared hash for retry matching and checks blob properties at completion; it does not download file bodies to verify hashes. Calculate hashes from the intended immutable source files; these are not server-verified integrity checksums. Unhashed historical submissions are not matched. For deliberate revalidation of identical bytes, send allowDuplicateContent: true with a new key after resolving any unfinished session.

Your integration owns the checkpoint store. Persist feed/Subfeed identity, deadline, source version or local checksum, key, submission ID, completed stage, attempt count, outcome, error code and next retry time. Save the intended key before creating a session and save after each stage. Never store private tokens or signed storage URLs in progress records.

After interruption, check current accepted state, then replay the original key for unfinished work. Only missing files receive fresh 15-minute upload targets. If a storage PUT response was lost, replay the session before another PUT; do not overwrite files or treat a storage error as proof of receipt. A size mismatch requires review. Repeat completion for the saved submission ID after an uncertain completion response. Completed session replay has no upload targets.

Allow at most five attempts per stage, including the first. Start retry delay at two seconds, double it up to a 60-second base delay, add 0–1 second of jitter, and honor any longer Retry-After. Retry transport failures, HTTP 408/429 and transient 500/502/503/504 errors using the appropriate recovery step. Exhausted retries remain RetryPending. The .NET SDK exposes RetryAfter. SDK calls make one attempt by default; opt into AutomationRateLimitRetryPolicy for quiet handling of confirmed upload throttling. Share it across workers for one organization, wrap one API call, and count its attempts in the same five-attempt stage budget. Other failures require the recovery steps above.

Record each Subfeed as Uploaded, AlreadyAccepted, Processing, ValidationFailed, SourceMissing, RetryPending, ActionRequired or Cancelled. Completion is not necessarily acceptance: keep pending validation as Processing and failed validation as ValidationFailed, and check period status for the current accepted submission. Keep successful results when another scope fails. Stop new organization work for authentication, organization access or quota failures; continue unrelated scopes after a scope-specific failure. Check Error.LimitKey for quota failures. Cancellation preserves the last checkpoint.

Set AutomationUploadRequestOptions.IdempotencyKey for recoverable uploads. Without a key, a matching open session can be reused, but an uncertain creation result requires reconciliation. Your integration owns the monthly run and saved progress; reports use individual upload sessions. API limits do not reserve durable slots for unfinished sessions.

Files

Upload and Download APIs

API and portal submissions follow the same file rules and history. Shared-feed routes address the shared exchange. For a partitioned feed, select the Subfeed by its stable key or a Partner’s current assignment before checking periods, uploading, or collecting accepted files. The basic shared-feed examples below are not Subfeed routes.

  1. Read the feed manifest to confirm expected files, extensions, upload limits, and scopes.
  2. Create an upload session for the target deadline period and list every file in the batch.
  3. Upload each file to its direct upload URL before the URL expires.
  4. Complete the submission so DTO Flow can match files, validate them, and update the period.
  5. Check the period by deadline before a downstream import or collection job starts.
  6. Request accepted file links and download each file before the download URLs expire.

API base URL: https://api.dtoflow.com. Send automation API requests with Authorization: Bearer <private-access-token>. The OpenAPI contract is available at https://api.dtoflow.com/api/automation/v1/openapi.json.

If your automation connects through a custom API host, use that same base URL for the OpenAPI document.

Partners

Partner-Partitioned Feed Automation

A partner-partitioned feed tracks independent Subfeeds, each of which can have several Partner recipients. Address uploads, period status, and accepted files by the stable Subfeed key. A Partner-key selector resolves that recipient's current assignment; the Partner does not exclusively own the Subfeed.

  • AutomationSubfeedIdentity identifies a subfeed through SubfeedId, SubfeedKey and SubfeedStatus. A subfeed can have several recipients or none. AutomationSubmission.PartnerId and PartnerKey identify the partner attributed to a submission and can be null for organization uploads. Partner access follows current grants and assignments.
  • Batch manifest, period-status, Partner, and Subfeed requests accept ordered correlation IDs and return an independent result for every item.
  • Operational grants remain separate from feed-level ManageSubfeeds and organization-level ManagePartners and InvitePartners. Readiness and assignment require ManageSubfeeds plus ManagePartners; invitations require InvitePartners and an existing Partner assignment to the route feed.
  • Use feed-level methods for Shared feeds. On partner-partitioned feeds, use Subfeed methods to select a destination by its Subfeed key or assigned Partner key.
GetSubfeedManifestBySubfeedKeyAsync(...) and GetSubfeedManifestByPartnerKeyAsync(...)
Resolves the exact Subfeed and returns the inherited parent-feed definition without duplicating configuration on the Subfeed.
GetSubfeedPeriodStatusBySubfeedKeyAsync(...) and GetSubfeedPeriodStatusByPartnerKeyAsync(...)
Reads readiness, validation, current accepted submission, and accepted-file availability for the selected Subfeed deadline.
CreateSubfeedUploadSessionBySubfeedKeyAsync(...) and CreateSubfeedUploadSessionByPartnerKeyAsync(...)
CompleteSubfeedUploadBySubfeedKeyAsync(...) and CompleteSubfeedUploadByPartnerKeyAsync(...)
Creates and completes an upload in the selected Subfeed scope. Recipient assignment remains separate from the submission's optional Partner attribution.
GetSubfeedAcceptedFilesBySubfeedKeyAsync(...) and GetSubfeedAcceptedFilesByPartnerKeyAsync(...)
Returns short-lived download links only for the accepted files in the selected Subfeed period.
GetSubfeedManifestBatchAsync(...) and GetSubfeedPeriodStatusesBatchAsync(...)
Resolves mixed Subfeed-key and Partner-key selectors in request order, with item-level errors for invalid selectors.
UpsertPartnerAsync(...) and UpsertPartnersBatchAsync(...)
Creates or updates canonical organization Partners individually or in batches. This alone does not grant feed access or assign a recipient to a Subfeed.
UpsertSubfeedByPartnerKeyAsync(...) and UpsertSubfeedsBatchAsync(...)
Creates or updates feed-scoped Subfeeds idempotently. Use PartnerKey null for an independent scope, then assign recipients separately. A Rejected item does not roll back unrelated results.
ActivateSubfeedByPartnerKeyAsync(...), ActivateSubfeedsBatchAsync(...), ArchiveSubfeedByPartnerKeyAsync(...), and ArchiveSubfeedsBatchAsync(...)
Activates or archives Subfeeds individually by Partner key or in independent-result batches.
CheckPartnerReadinessBatchAsync(...)
Reads Partner identity, feed access, intended Subfeed, assignment, and invitation state with RequiredActions, SubfeedUploadReady, and PortalReady. No records are changed and no emails are requested.
AssignPartnersToFeedAsync(...), AssignPartnersToSubfeedsBatchAsync(...), UnassignPartnersFromSubfeedsBatchAsync(...)
Feed access grants a role; Subfeed assignment selects the recipient's report scope. Unassignment removes access to that scope and its history. These are explicit writes, separate from catalog upserts.
InvitePartnersAsync(...)
Creates or reuses onboarding invitations for assigned recipients. Choose ReturnLink for your secure handoff or Email to request DTO Flow email delivery; inspect every item's result.
Code sample

Check recipient readiness before making changes

This read-only example describes one intended recipient and prints the actions still needed. It does not create access, assign recipients, or send invitations. Apply supported actions separately using the sequence below.

check-recipients.cs
using DtoFlow.Integration;
using DtoFlow.Integration.Models;

var baseUrl = Environment.GetEnvironmentVariable("DTOFLOW_BASE_URL") ?? "https://api.dtoflow.com";
var token = Environment.GetEnvironmentVariable("DTOFLOW_TOKEN")
    ?? throw new InvalidOperationException("Set DTOFLOW_TOKEN.");
var feedSlug = Environment.GetEnvironmentVariable("DTOFLOW_FEED_SLUG") ?? "supplier-prices";
var partnerKey = Environment.GetEnvironmentVariable("DTOFLOW_PARTNER_KEY") ?? "northwind";
var subfeedKey = Environment.GetEnvironmentVariable("DTOFLOW_SUBFEED_KEY") ?? "northwind-prices";

using var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseDefaultCredentials = false }) { BaseAddress = new Uri(baseUrl) };
var client = new DtoFlowAutomationClient(httpClient, token);

var readiness = await client.CheckPartnerReadinessBatchAsync(feedSlug, new[]
{
    new AutomationPartnerReadinessItem(
        "recipient-1", partnerKey, "Northwind Foods", "recipient@example.invalid",
        "PartnerObserver", subfeedKey, "Active")
});

foreach (var result in readiness.Results)
{
    Console.WriteLine($"{result.CorrelationId}: {result.Outcome} {result.Error?.Code}");
    Console.WriteLine($"Upload ready: {result.SubfeedUploadReady}; portal ready: {result.PortalReady}");
    Console.WriteLine(string.Join(", ", result.RequiredActions));
}

Operating a recurring Subfeed integration

Separate recipient setup from report publication. Use the readiness response to plan changes, then track each Subfeed and reporting period independently.

1. Check setup before changing recipients

In DTO Flow, configure the feed direction, Partner-partitioned tracking scope, expected file definitions, schedule, and token grants. Read the manifest to check file rules and limits before any writes. A successful manifest read does not prove management permissions or recipient readiness.

Keep integration settings in your own application. Before the first upload, use read-only calls to check accessible feeds, the manifest and the selected period. Multi-file integrations must supply the complete required file set. PDF and other archive-only files are kept unchanged, without spreadsheet validation.

2. Decide who owns identities and access

A Partner represents a recipient; a Subfeed is an independent report scope shared by its assigned recipients. Keep stable business keys. Creating a Partner, granting a feed role, creating a Subfeed, and assigning that Partner to it are separate operations. A Partner has one current Subfeed assignment within a partner-partitioned feed.

Upserts change only supplied records. Omitted recipients are not unassigned and omitted Subfeeds are not archived. Offboard explicitly: unassign the recipient before removing feed access, and archive only when retiring the whole scope. Assignment grants access to existing Subfeed history; removal revokes it. Decide whether your source system or portal administrators own each field so reconciliation does not undo intended edits.

3. Apply required actions in dependency order

CheckPartnerReadinessBatchAsync is read-only. Apply understood RequiredActions in order: Partner identity, feed access/role, Subfeed lifecycle, assignment, then invitation. Create a shared Subfeed once with PartnerKey null, then assign its recipients explicitly. Read readiness again after writes; stop on rejected results or unknown actions.

SubfeedUploadReady and PortalReady answer different questions. Invitation acceptance and email confirmation can still be pending while organization uploads are allowed. Identity or access conflicts need attention. Review recipient readiness before submitting to a shared Subfeed and apply your organization’s publication policy.

4. Separate invitation creation, delivery, and acceptance

InvitePartnersAsync supports ReturnLink and Email. ReturnLink returns a bearer invitation for your secure handoff without requesting invitation email. Email queues delivery. Neither proves the recipient accepted or can sign in. ReturnLink does not stop access emails for newly granted feed access to existing active accounts, configured alerts, or previously queued emails.

To switch from ReturnLink to Email, explicitly request Email for a pending invitation whose EmailDeliveryStatus is NotRequested. Leave queued/sent delivery alone; inspect failed or suppressed delivery before deliberately requesting another invitation. If a custom handoff was lost, reconcile your delivery record and request ReturnLink again under the reuse/expiry rules. AwaitInvitationAcceptance does not itself repeat that handoff. Never log the bearer link; use .invalid addresses for initial tests and addresses you control for sign-in tests.

5. Recover from partial or uncertain results

Split batches at the documented 1,000-item limit and use unique correlation IDs for each request item. Inspect every result, including Rejected and Error, even when the HTTP request succeeds. One failed item does not roll back successful items. Preserve the item-to-source mapping; an absent result must not be interpreted as removal or success.

After an interrupted management request, read readiness again to learn what succeeded and apply only remaining actions. Upload recovery uses its persisted idempotency key and immutable source instead. Keep completed scopes complete, retry transient failures with bounded backoff, and surface permission, identity, quota, and validation problems for an operator.

6. Select the reporting period once, then keep it stable

Save the feed schedule in DTO Flow and use the period preview's integration values as setup examples. Your scheduler calculates each deadline from business reporting data and the saved schedule/time zone, then reads period status by deadline to obtain the canonical periodKey. The API selects by deadlineAt; it does not require callers to send both values or expose a separate /current endpoint.

Preserve the selected period and scheduleRevision with the report version and Idempotency-Key. New work using an old revision returns HTTP 409 PeriodSelectionStale: refresh the catalog and select again. A matching existing key recovers the original submission first, even after a schedule change, keeping its saved period and deadline. Changing a selection with the same key returns IdempotencyKeyConflict. Do not silently refresh the selection while recovering an existing upload.

7. Distinguish retries from deliberate corrections

First check the selected Subfeed period for an accepted submission. Skip it for an ordinary run; use a new source/attempt version and idempotency key for a deliberate correction. With contentSha256 on every file, DTO Flow can reuse an identical complete file set within the same scope and period. It uses caller-declared hashes for retry matching without downloading blobs to recalculate them.

Processing means validation is unfinished; ValidationFailed needs review. Neither should cause repeated identical uploads. Changed files or a changed complete file set can create a new submission; unchanged files can also be reused in another Subfeed or period. To deliberately revalidate an identical set, use a new key and allowDuplicateContent: true. Resolve an open session first.

8. Keep progress and schedule recovery in your system

Persist scope, period and deadline, immutable source files and version, retry key, submission ID and outcome. Keep source bytes available across restarts and prevent overlapping writers. Removing a scope from your source catalog does not delete remote state; retain its recovery history.

Your scheduler decides when RetryPending work runs again. Honor Retry-After and attempt limits; for Processing, check the submission later or handle its completion webhook. The SDK does not schedule jobs and each call makes one attempt by default. Log safe error codes and submission IDs, keep secrets out of logs, and pin the SDK version in your integration.

Upload retry keys, throttling, and session recovery · Submitting multiple files together

Examples

Basic File Round Trip

Start with one small, repeatable exchange: upload an expected file, complete the submission, and collect the accepted file when the period is ready.

  • Upload CSV, Excel, ZIP, or other expected files from a scheduled folder.
  • Check whether the current period has an accepted file before a downstream import starts.
  • Download the current accepted original when it is ready.

.NET SDK: DtoFlow.Integration 1.0.0 on NuGet

The stable SDK supports feed discovery, period selection, file uploads and downloads, and webhook signature verification. It targets .NET Standard 2.0 and works with compatible newer platforms.

Code sample

Install the package

.NET CLI
dotnet add package DtoFlow.Integration --version 1.0.0
Code sample

Upload a file, complete the submission, and collect accepted files

Use environment variables for secrets and deployment-specific feed settings. The .NET examples publish downloaded files only after a complete transfer and keep existing destinations unchanged.

Program.cs
using System.Globalization;
using DtoFlow.Integration;
using DtoFlow.Integration.Models;

var baseUrl = Environment.GetEnvironmentVariable("DTOFLOW_BASE_URL") ?? "https://api.dtoflow.com";
var token = Environment.GetEnvironmentVariable("DTOFLOW_TOKEN")
    ?? throw new InvalidOperationException("Set DTOFLOW_TOKEN.");
var feedSlug = Environment.GetEnvironmentVariable("DTOFLOW_FEED_SLUG") ?? "supplier-prices";
var filePath = args.Length > 0 ? args[0] : "prices.csv";
var deadlineAt = DateTimeOffset.Parse(
    Environment.GetEnvironmentVariable("DTOFLOW_DEADLINE_AT") ?? "2026-06-01T12:00:00Z",
    CultureInfo.InvariantCulture);

using var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseDefaultCredentials = false }) { BaseAddress = new Uri(baseUrl) };
var client = new DtoFlowAutomationClient(httpClient, token);
var fileInfo = new FileInfo(filePath);

try
{
    var session = await client.CreateUploadSessionAsync(
        feedSlug,
        deadlineAt,
        new[]
        {
            new CreateAutomationUploadFileRequest(
                FeedFileSlug: null,
                OriginalFileName: fileInfo.Name,
                ContentType: "text/csv",
                SizeBytes: fileInfo.Length)
        });

    using var upload = File.OpenRead(filePath);
    await client.UploadFileAsync(session.Files[0], upload);
    await client.CompleteUploadAsync(feedSlug, session.SubmissionId);

    AutomationPeriodStatus? period = null;
    for (var attempt = 0; attempt < 30; attempt++)
    {
        period = await client.GetPeriodStatusByDeadlineAsync(feedSlug, deadlineAt);
        if (period.AcceptedFilesAvailable)
        {
            break;
        }

        if (period.Status is "ValidationFailed" or "Rejected" or "Archived")
        {
            throw new InvalidOperationException(
                $"Submission {period.SubmissionId} finished with status {period.Status}.");
        }

        await Task.Delay(TimeSpan.FromSeconds(2));
    }

    if (period?.AcceptedFilesAvailable != true)
    {
        throw new TimeoutException("The accepted file was not ready within one minute.");
    }

    var accepted = await client.GetAcceptedFilesByDeadlineAsync(feedSlug, deadlineAt);
    foreach (var acceptedFile in accepted.Files)
    {
        var destinationPath = ResolveDownloadPath(
            Directory.GetCurrentDirectory(),
            acceptedFile.SubmittedFileId);
        await SaveDownloadAsync(destinationPath,
            (destination, ct) => client.DownloadAcceptedFileAsync(acceptedFile, destination, ct));
        Console.WriteLine($"Downloaded {destinationPath}");
    }
}
catch (DtoFlowAutomationException ex) when (ex.IsTenantAccessReadOnly)
{
    Console.Error.WriteLine($"Tenant access is {ex.TenantAccessState}; required action: {ex.TenantAccessRequiredAction}.");
    Environment.ExitCode = 2;
}

static string ResolveDownloadPath(string downloadDirectory, Guid submittedFileId)
{
    // Use the file identity, never an untrusted remote filename, as the local name.
    return Path.Combine(Path.GetFullPath(downloadDirectory), $"{submittedFileId:N}.original");
}

static async Task SaveDownloadAsync(string destinationPath,
    Func<Stream, CancellationToken, Task> download, CancellationToken cancellationToken = default)
{
    ArgumentNullException.ThrowIfNull(download);
    cancellationToken.ThrowIfCancellationRequested();
    var fullPath = Path.GetFullPath(destinationPath);
    if (File.Exists(fullPath)) throw new IOException("The download destination already exists.");
    var temporaryPath = Path.Combine(Path.GetDirectoryName(fullPath)!, $".dtoflow-{Guid.NewGuid():N}.partial");
    var output = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
    try
    {
        await using (output)
        {
            await download(output, cancellationToken);
        }
        cancellationToken.ThrowIfCancellationRequested();
        File.Move(temporaryPath, fullPath); // Same directory; never replace an existing destination.
    }
    finally
    {
        File.Delete(temporaryPath); // Only this attempt's file; a completed move leaves nothing here.
    }
}
Reference

API and .NET Reference Methods

The methods below are available in DtoFlow.Integration 1.0.0. Integrations on other platforms can use raw HTTP or the published OpenAPI contract.

GetManifestAsync(fileFeedSlug)
Reads feed direction, status, upload limits, expected files, matching rules, and token scopes so each run can confirm the current setup.
GetPeriodStatusByDeadlineAsync(fileFeedSlug, deadlineAt)
Checks whether the expected period has a submission, whether validation finished, and whether accepted files are available for download.
CreateUploadSessionAsync(...) and CompleteUploadAsync(...)
Creates direct upload targets, then marks the submission ready for matching and validation after every file has been uploaded.
GetAcceptedFilesByDeadlineAsync(...) and DownloadAcceptedFileAsync(...)
Gets short-lived direct download URLs for the current accepted submission and streams each accepted file into your local destination.
Code sample

Read the manifest and check period readiness

Use this before starting a downstream import or collection job.

check-period.cs
using System.Globalization;
using System.Linq;
using DtoFlow.Integration;

var baseUrl = Environment.GetEnvironmentVariable("DTOFLOW_BASE_URL") ?? "https://api.dtoflow.com";
var token = Environment.GetEnvironmentVariable("DTOFLOW_TOKEN")
    ?? throw new InvalidOperationException("Set DTOFLOW_TOKEN.");
var feedSlug = Environment.GetEnvironmentVariable("DTOFLOW_FEED_SLUG") ?? "supplier-prices";
var deadlineAt = DateTimeOffset.Parse(
    Environment.GetEnvironmentVariable("DTOFLOW_DEADLINE_AT") ?? "2026-06-01T12:00:00Z",
    CultureInfo.InvariantCulture);

using var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseDefaultCredentials = false }) { BaseAddress = new Uri(baseUrl) };
var client = new DtoFlowAutomationClient(httpClient, token);

var manifest = await client.GetManifestAsync(feedSlug);
Console.WriteLine($"Feed: {manifest.Name} ({manifest.Slug})");
Console.WriteLine($"Direction: {manifest.Direction}; status: {manifest.Status}");
Console.WriteLine($"Token scopes: {string.Join(", ", manifest.TokenScopes)}");

foreach (var file in manifest.Files.OrderBy(file => file.SortOrder))
{
    Console.WriteLine(
        $"- {file.Slug}: {file.ExpectedExtension}, required={file.RequiredInSubmission}, validation={file.IsValidationEnabled}");
}

var period = await client.GetPeriodStatusByDeadlineAsync(feedSlug, deadlineAt);
if (!period.HasSubmission)
{
    Console.WriteLine("No submission has been completed for this period yet.");
    return;
}

if (!period.Accepted || !period.AcceptedFilesAvailable)
{
    Console.WriteLine($"Submission {period.SubmissionId} is {period.Status}; validation is {period.ValidationStatus}.");
    return;
}

var accepted = await client.GetAcceptedFilesByDeadlineAsync(feedSlug, deadlineAt);
Console.WriteLine($"{accepted.Files.Count} accepted file(s) are ready from {accepted.SubmissionId}.");
Batches

Uploading and Downloading Multiple Files

Multi-file feeds work best when each expected file has a feed file slug. Include every file in the upload session request, upload each returned target, complete the submission once, and then download all accepted files for the period once DTO Flow accepts the submission.

  • Use feed file slugs for strict expected files, such as prices or inventory.
  • Use FeedFileSlug: null when DTO Flow should match one expected file by filename, or for an allowed additional file. Prefer explicit slugs in multi-file jobs to avoid ambiguity.
  • Complete the submission after all uploads finish, not after each individual file.
  • Download URLs are short-lived, so request fresh accepted-file links for each run.
Code sample

Upload and download a multi-file feed period

The same pattern works for two files or a larger expected file set.

multi-file-sync.cs
using System.Globalization;
using System.Linq;
using DtoFlow.Integration;
using DtoFlow.Integration.Models;

var baseUrl = Environment.GetEnvironmentVariable("DTOFLOW_BASE_URL") ?? "https://api.dtoflow.com";
var token = Environment.GetEnvironmentVariable("DTOFLOW_TOKEN")
    ?? throw new InvalidOperationException("Set DTOFLOW_TOKEN.");
var feedSlug = Environment.GetEnvironmentVariable("DTOFLOW_FEED_SLUG") ?? "weekly-pack";
var deadlineAt = DateTimeOffset.Parse(
    Environment.GetEnvironmentVariable("DTOFLOW_DEADLINE_AT") ?? "2026-06-01T12:00:00Z",
    CultureInfo.InvariantCulture);
var downloadDirectory = Environment.GetEnvironmentVariable("DTOFLOW_DOWNLOAD_DIR") ?? "accepted";

var uploadFiles = new[]
{
    new { FeedFileSlug = "prices", Path = "incoming/prices.csv", ContentType = "text/csv" },
    new { FeedFileSlug = "inventory", Path = "incoming/inventory.xlsx", ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
};

using var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseDefaultCredentials = false }) { BaseAddress = new Uri(baseUrl) };
var client = new DtoFlowAutomationClient(httpClient, token);

try
{
var session = await client.CreateUploadSessionAsync(
    feedSlug,
    deadlineAt,
    uploadFiles.Select(file =>
    {
        var fileInfo = new FileInfo(file.Path);
        return new CreateAutomationUploadFileRequest(
            file.FeedFileSlug,
            fileInfo.Name,
            file.ContentType,
            fileInfo.Length);
    }).ToArray());

var uploadTargets = session.Files.ToDictionary(
    file => file.OriginalFileName,
    StringComparer.OrdinalIgnoreCase);

foreach (var uploadFile in uploadFiles)
{
    var fileName = Path.GetFileName(uploadFile.Path);
    await using var stream = File.OpenRead(uploadFile.Path);
    await client.UploadFileAsync(uploadTargets[fileName], stream);
    Console.WriteLine($"Uploaded {fileName}.");
}

await client.CompleteUploadAsync(feedSlug, session.SubmissionId);

var period = await client.GetPeriodStatusByDeadlineAsync(feedSlug, deadlineAt);
if (!period.AcceptedFilesAvailable)
{
    Console.WriteLine($"Submission {session.SubmissionId} is {period.Status}; try download after validation completes.");
    return;
}

Directory.CreateDirectory(downloadDirectory);
var accepted = await client.GetAcceptedFilesByDeadlineAsync(feedSlug, deadlineAt);

foreach (var acceptedFile in accepted.Files)
{
    var destinationPath = ResolveDownloadPath(downloadDirectory, acceptedFile.SubmittedFileId);
    await SaveDownloadAsync(destinationPath,
        (destination, ct) => client.DownloadAcceptedFileAsync(acceptedFile, destination, ct));
    Console.WriteLine($"Downloaded {destinationPath}.");
}
}
catch (DtoFlowAutomationException ex) when (ex.IsTenantAccessReadOnly)
{
    Console.Error.WriteLine($"Tenant access is {ex.TenantAccessState}; required action: {ex.TenantAccessRequiredAction}.");
    Environment.ExitCode = 2;
}

static string ResolveDownloadPath(string downloadDirectory, Guid submittedFileId)
{
    // Use the file identity, never an untrusted remote filename, as the local name.
    return Path.Combine(Path.GetFullPath(downloadDirectory), $"{submittedFileId:N}.original");
}

static async Task SaveDownloadAsync(string destinationPath,
    Func<Stream, CancellationToken, Task> download, CancellationToken cancellationToken = default)
{
    ArgumentNullException.ThrowIfNull(download);
    cancellationToken.ThrowIfCancellationRequested();
    var fullPath = Path.GetFullPath(destinationPath);
    if (File.Exists(fullPath)) throw new IOException("The download destination already exists.");
    var temporaryPath = Path.Combine(Path.GetDirectoryName(fullPath)!, $".dtoflow-{Guid.NewGuid():N}.partial");
    var output = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
    try
    {
        await using (output)
        {
            await download(output, cancellationToken);
        }
        cancellationToken.ThrowIfCancellationRequested();
        File.Move(temporaryPath, fullPath); // Same directory; never replace an existing destination.
    }
    finally
    {
        File.Delete(temporaryPath); // Only this attempt's file; a completed move leaves nothing here.
    }
}