Skip to content
6 min read Guides Vapor

What's New in Vapor SDK 2.0 and 3.0

Vapor SDK 2.0.0 and 3.0.0 were both tagged on 14 October 2026, under an hour apart. They follow 1.1.1, the April release that removed an empty test target, and they are the earliest changes to what the package does since the Vapor SDK 1.0 introduction. Each one is a major version because each one breaks some code that compiled against the version before it, so this post covers both, with a migration section for each step.

The short version

  • 2.0.0, breaking: SyncAppleTransactionRequest takes the signed StoreKit 2 transaction, and three properties with an uppercase acronym are renamed so they decode.
  • 2.0.0, fixed: one request with a status or category newer than the SDK no longer fails the whole list.
  • 3.0.0, breaking: new FeedbackKitError cases, a 429 arrives as rateLimited, and deleting a comment needs a user id or an account token.
  • 3.0.0, new: page and perPage on the feedback and comment lists, and the sync state of each ticket on a request.
  • 3.0.0, fixed: query values are percent-encoded, comments on a request hidden over the Free plan cap reach their creator, and changePassword works.

What changed in 2.0

Properties that never decoded

The package decodes the server's snake_case keys with .convertFromSnakeCase, which turns total_mrr into totalMrr. A property spelled totalMRR can never match that key. In 1.1.1 that broke two things:

  • SDKUsersStats declared totalMRR, usersWithMRR and averageMRR, so every users.stats(projectId:) and users.allStats() call on the account-token client threw a decoding error. They are now totalMrr, usersWithMrr and averageMrr, and the calls return.
  • ProjectResponse.slackWebhookURL was optional, so instead of throwing it was nil on every project read. It is now slackWebhookUrl, the same spelling the request types already used.

A tolerant feedback list

feedback.list used to decode the response as one array, so a single request whose status or category this SDK did not know failed the whole call. Such rows are now dropped and the rest come back in order. A row that is malformed in some other way, with a missing key or a wrong type, still throws.

A delete that never worked

feedback.delete(id:) on the project-key client is deprecated. The server accepts that delete with an account token alone, so the project-key call had answered 401 since 1.0. It now throws the new FeedbackKitError.operationNotSupported without sending a request. Delete through req.feedbackKitAdmin(token:).feedback.delete(id:) instead.

Breaking changes in 2.0 and how to migrate

Apple transaction sync

SyncAppleTransactionRequest now carries the StoreKit 2 signed transaction, the JWS from VerificationResult.jwsRepresentation, instead of the original transaction id and the product id. The server reads both ids from the signed transaction. Have your app send the JWS to your server, then pass it on:

// Vapor SDK 1.1.1
let sync = SyncAppleTransactionRequest(
    originalTransactionId: body.originalTransactionId,
    productId: body.productId
)

// Vapor SDK 2.0.0
let sync = SyncAppleTransactionRequest(signedTransaction: body.signedTransaction)
_ = try await req.feedbackKitAdmin(token: token).subscriptions.syncApple(sync)

Renamed properties

Rename totalMRR, usersWithMRR and averageMRR to totalMrr, usersWithMrr and averageMrr, and slackWebhookURL to slackWebhookUrl. The compiler finds each one. Code that read the user stats could not have worked in 1.1.1, since the decode threw before it ran. Code that read slackWebhookURL compiled and always saw nil, so check what it did with that.

Exhaustive switches

FeedbackKitError gained operationNotSupported(String). An exhaustive switch over it needs that case or a default. Because the error conforms to Vapor's AbortError, a route that lets it propagate answers 500 with the message as the reason.

What changed in 3.0

Paging on the lists

feedback.list and comments.list(feedbackId:) take page and perPage. Leave page out and the SDK reads every page, up to 100 of them, and returns the whole collection as before. Set it and you get that page alone, which suits a web page of your own that shows the board a screen at a time. The server keeps perPage between 1 and 100:

app.get("ideas") { req async throws -> [FeedbackResponse] in
    let page = req.query[Int.self, at: "page"] ?? 1
    return try await req.feedbackKit.feedback.list(
        status: .approved,
        page: page,
        perPage: 20
    )
}

Ticket sync state

FeedbackResponse.syncStates lists, for each integration a request was sent to, the provider, its sync state and a link to the ticket when there is one. It is optional and nil from a server that does not send it.

Fixes

  • Query values are percent-encoded. They used to be joined into the URL as written, so an & or = in a value added a parameter, a # cut the value short, a + arrived as a space and a % was decoded twice. Every query the package builds, from the feedback list to the billing portal's returnUrl, now encodes its values. Values that were already safe are sent exactly as before.
  • Comments on a hidden request reach its creator. On the Free plan, a request over the project's cap is hidden from the board, but the person who filed it can still read and comment on it. The server checks who is asking through the X-User-Id header, and comments.create put the user id in the body alone, so the creator got a 404. Create now sends the header, and comments.list(feedbackId:userId:) sends it when you pass a user id.
  • auth.changePassword(_:) changes the password. It sent a POST with an empty body to a route that takes a PUT with the current and new passwords. It now sends the PUT.
  • Concurrent requests get their own JSON coder. The package shared one decoder and one encoder across every request a Vapor server handled at once. Each use now builds its own.
  • Decode failures no longer log field values. The log line names the type that failed. The thrown decodingError still carries the detail.

Breaking changes in 3.0 and how to migrate

New error cases

FeedbackKitError gained three cases: rateLimited(message:) for a 429, and tierUpgradeRequired(code:message:) and measureNotAvailable(measure:message:), which the feedback, vote, comment and user calls do not raise. An exhaustive switch needs them or a default. A route that lets the error propagate answers 429, 402 and 403 for them.

A 429 is rateLimited

In 2.0 a 429 arrived as serverError(statusCode: 429). It is now rateLimited(message:). Code that matched serverError to spot a throttled request stops seeing it there. A 422 still arrives as serverError(statusCode: 422).

do {
    _ = try await req.feedbackKit.votes.vote(feedbackId: id, request: vote)
} catch FeedbackKitError.rateLimited {
    throw Abort(.tooManyRequests, reason: "Too many votes. Try again in a moment.")
} catch FeedbackKitError.conflict {
    // Already voted: treat as success.
}

Deleting a comment

comments.delete(feedbackId:commentId:) now takes userId or bearerToken. A user id deletes that user's own comment, with your project key. An account token deletes any comment as a project owner or admin, without the project key. If both are set the token is used. Called with neither, it throws operationNotSupported without sending a request, because a project key alone cannot show who is deleting:

// Vapor SDK 2.0.0
try await req.feedbackKit.comments.delete(feedbackId: id, commentId: commentID)

// Vapor SDK 3.0.0: the user removes their own comment
try await req.feedbackKit.comments.delete(
    feedbackId: id,
    commentId: commentID,
    userId: account.id
)

How FeedbackKit fits in

Votes and comments your server records count on the same board as the ones from your apps, under the same user id. Your team sorts that board by votes, replies, and moves each request through Approved, In Progress and TestFlight to Completed. A user who left an email hears about status changes by email. Here is how voting looks in FeedbackKit, with sample data from Trailmate, a fictional hiking app:

Votes from your app and your server

A vote recorded by your Vapor server lands on the same board as the votes cast in your apps.

Upgrading

Both versions resolve through Swift Package Manager from their git tags. Require the major version you want in Package.swift:

.package(
    url: "https://github.com/Swiftly-Developed/SwiftlyFeedbackKit-Vapor-SDK.git",
    "3.0.0"..<"4.0.0"
),

Use "2.0.0"..<"3.0.0" to stop at 2.0, or exact: "3.0.0" to pin one tag. A requirement written against 1.1.1 does not move to either version on its own, because both are new major versions.

From 1.1.1 to 2.0: change the Apple sync request and rename the four properties.

From 2.0 to 3.0: add the three error cases to any exhaustive switch, move a 429 check from serverError to rateLimited, and pass a userId or bearerToken to every comment delete.

From 1.0: do both steps. Since 1.1.0, .production points at api.prod.getfeedbackkit.com, so the .custom address the 1.0 post recommended is no longer needed. The package still needs Swift 6.2, Vapor 4.89 or later and macOS 13 on Apple platforms.

The reference for every call is in the Vapor SDK docs. The Swift SDK moved on the same day, in Swift SDK 1.3, and pricing lists what each plan includes.

Every plan includes the Vapor SDK. Start free and connect your server to a project.

Keep reading

Collect feedback in your own app

FeedbackKit ships SDKs for Swift, Kotlin, React Native, Flutter and JavaScript, and syncs what your users ask for into the tracker your team already uses.