diff --git a/EbayListingTool/Services/EbayListingService.cs b/EbayListingTool/Services/EbayListingService.cs
index 30bf643..a8403aa 100644
--- a/EbayListingTool/Services/EbayListingService.cs
+++ b/EbayListingTool/Services/EbayListingService.cs
@@ -12,17 +12,39 @@ public class EbayListingService
private readonly EbayAuthService _auth;
private readonly EbayCategoryService _categoryService;
+ // Shared clients — avoids socket exhaustion from per-call `new HttpClient()`
+ private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs
+ private static readonly HttpClient _photoHttp = new(); // Trading API (photo upload)
+
+ // Per-session cache of eBay account IDs — fetched once, reused for every listing
+ private string? _fulfillmentPolicyId;
+ private string? _paymentPolicyId;
+ private string? _returnPolicyId;
+ private string? _merchantLocationKey;
+
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
{
_auth = auth;
_categoryService = categoryService;
}
+ /// Call when the user disconnects so stale IDs are not reused after re-login.
+ public void ClearCache()
+ {
+ _fulfillmentPolicyId = null;
+ _paymentPolicyId = null;
+ _returnPolicyId = null;
+ _merchantLocationKey = null;
+ }
+
public async Task PostListingAsync(ListingDraft draft)
{
var token = await _auth.GetValidAccessTokenAsync();
- // 1. Upload photos and get URLs
+ // Resolve business policies and merchant location before touching inventory/offers
+ await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
+
+ // 1. Upload photos and get eBay-hosted URLs
var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
// 2. Resolve category if not set
@@ -51,31 +73,156 @@ public class EbayListingService
return draft.EbayListingUrl;
}
+ // ---- Setup: policies + location ----
+
+ ///
+ /// Fetches fulfillment, payment and return policy IDs from the seller's eBay account,
+ /// and ensures at least one merchant location exists (creating "home" from the seller's
+ /// postcode if needed). Results are cached for the session.
+ ///
+ private async Task EnsurePoliciesAndLocationAsync(string token, string postcode)
+ {
+ var baseUrl = _auth.BaseUrl;
+
+ if (_fulfillmentPolicyId == null)
+ {
+ using var req = MakeRequest(HttpMethod.Get,
+ $"{baseUrl}/sell/account/v1/fulfillment_policy?marketplace_id=EBAY_GB", token);
+ var res = await _http.SendAsync(req);
+ var json = await res.Content.ReadAsStringAsync();
+
+ if (!res.IsSuccessStatusCode)
+ throw new HttpRequestException(
+ $"Could not fetch fulfillment policies ({(int)res.StatusCode}): {json}");
+
+ var arr = JObject.Parse(json)["fulfillmentPolicies"] as JArray;
+ _fulfillmentPolicyId = arr?.Count > 0
+ ? arr[0]["fulfillmentPolicyId"]?.ToString()
+ : null;
+
+ if (_fulfillmentPolicyId == null)
+ throw new InvalidOperationException(
+ "No fulfillment policy found on your eBay account.\n\n" +
+ "Please set one up in My eBay → Account → Business policies, then try again.");
+ }
+
+ if (_paymentPolicyId == null)
+ {
+ using var req = MakeRequest(HttpMethod.Get,
+ $"{baseUrl}/sell/account/v1/payment_policy?marketplace_id=EBAY_GB", token);
+ var res = await _http.SendAsync(req);
+ var json = await res.Content.ReadAsStringAsync();
+
+ if (!res.IsSuccessStatusCode)
+ throw new HttpRequestException(
+ $"Could not fetch payment policies ({(int)res.StatusCode}): {json}");
+
+ var arr = JObject.Parse(json)["paymentPolicies"] as JArray;
+ _paymentPolicyId = arr?.Count > 0
+ ? arr[0]["paymentPolicyId"]?.ToString()
+ : null;
+
+ if (_paymentPolicyId == null)
+ throw new InvalidOperationException(
+ "No payment policy found on your eBay account.\n\n" +
+ "Please set one up in My eBay → Account → Business policies, then try again.");
+ }
+
+ if (_returnPolicyId == null)
+ {
+ using var req = MakeRequest(HttpMethod.Get,
+ $"{baseUrl}/sell/account/v1/return_policy?marketplace_id=EBAY_GB", token);
+ var res = await _http.SendAsync(req);
+ var json = await res.Content.ReadAsStringAsync();
+
+ if (!res.IsSuccessStatusCode)
+ throw new HttpRequestException(
+ $"Could not fetch return policies ({(int)res.StatusCode}): {json}");
+
+ var arr = JObject.Parse(json)["returnPolicies"] as JArray;
+ _returnPolicyId = arr?.Count > 0
+ ? arr[0]["returnPolicyId"]?.ToString()
+ : null;
+
+ if (_returnPolicyId == null)
+ throw new InvalidOperationException(
+ "No return policy found on your eBay account.\n\n" +
+ "Please set one up in My eBay → Account → Business policies, then try again.");
+ }
+
+ if (_merchantLocationKey == null)
+ {
+ using var req = MakeRequest(HttpMethod.Get,
+ $"{baseUrl}/sell/inventory/v1/location", token);
+ var res = await _http.SendAsync(req);
+ var json = await res.Content.ReadAsStringAsync();
+
+ if (res.IsSuccessStatusCode)
+ {
+ var arr = JObject.Parse(json)["locations"] as JArray;
+ _merchantLocationKey = arr?.Count > 0
+ ? arr[0]["merchantLocationKey"]?.ToString()
+ : null;
+ }
+
+ // No existing locations — create one from the seller's postcode
+ if (_merchantLocationKey == null)
+ {
+ await CreateMerchantLocationAsync(token, postcode);
+ _merchantLocationKey = "home";
+ }
+ }
+ }
+
+ private async Task CreateMerchantLocationAsync(string token, string postcode)
+ {
+ if (string.IsNullOrWhiteSpace(postcode))
+ postcode = "N/A"; // eBay allows this when postcode is genuinely unknown
+
+ var body = new
+ {
+ location = new
+ {
+ address = new { postalCode = postcode, country = "GB" }
+ },
+ locationTypes = new[] { "WAREHOUSE" },
+ name = "Home",
+ merchantLocationStatus = "ENABLED"
+ };
+
+ using var req = MakeRequest(HttpMethod.Post,
+ $"{_auth.BaseUrl}/sell/inventory/v1/location/home", token);
+ req.Content = new StringContent(
+ JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
+
+ var res = await _http.SendAsync(req);
+ var json = await res.Content.ReadAsStringAsync();
+
+ if (!res.IsSuccessStatusCode)
+ throw new HttpRequestException(
+ $"Could not create merchant location ({(int)res.StatusCode}): {json}");
+ }
+
+ // ---- Inventory item ----
+
private async Task CreateInventoryItemAsync(ListingDraft draft, List imageUrls, string token)
{
- using var http = BuildClient(token);
-
- var aspects = new Dictionary>();
-
var inventoryItem = new
{
availability = new
{
- shipToLocationAvailability = new
- {
- quantity = draft.Quantity
- }
+ shipToLocationAvailability = new { quantity = draft.Quantity }
},
condition = draft.ConditionId,
- conditionDescription = draft.Condition == ItemCondition.Used ? "Used - see photos" : null,
+ conditionDescription = draft.Condition == ItemCondition.Used ? "Used — see photos" : null,
description = draft.Description,
title = draft.Title,
product = new
{
- title = draft.Title,
+ title = draft.Title,
description = draft.Description,
- imageUrls = imageUrls.Count > 0 ? imageUrls : null,
- aspects = aspects.Count > 0 ? aspects : null
+ imageUrls = imageUrls.Count > 0 ? imageUrls : null,
+ aspects = (object?)null
}
};
@@ -85,40 +232,41 @@ public class EbayListingService
});
var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
- var request = new HttpRequestMessage(HttpMethod.Put, url)
- {
- Content = new StringContent(json, Encoding.UTF8, "application/json")
- };
- request.Content.Headers.Add("Content-Language", "en-GB");
+ using var req = MakeRequest(HttpMethod.Put, url, token);
+ req.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ req.Content.Headers.Add("Content-Language", "en-GB");
- var response = await http.SendAsync(request);
- if (!response.IsSuccessStatusCode)
+ var res = await _http.SendAsync(req);
+ if (!res.IsSuccessStatusCode)
{
- var error = await response.Content.ReadAsStringAsync();
- throw new HttpRequestException($"Failed to create inventory item: {error}");
+ var err = await res.Content.ReadAsStringAsync();
+ throw new HttpRequestException($"Failed to create inventory item: {err}");
}
}
+ // ---- Offer ----
+
private async Task CreateOfferAsync(ListingDraft draft, string token)
{
- using var http = BuildClient(token);
-
- var listingPolicies = BuildListingPolicies(draft);
-
var offer = new
{
- sku = draft.Sku,
- marketplaceId = "EBAY_GB",
- format = draft.Format == ListingFormat.Auction ? "AUCTION" : "FIXED_PRICE",
- availableQuantity = draft.Quantity,
- categoryId = draft.CategoryId,
+ sku = draft.Sku,
+ marketplaceId = "EBAY_GB",
+ format = draft.Format == ListingFormat.Auction ? "AUCTION" : "FIXED_PRICE",
+ availableQuantity = draft.Quantity,
+ categoryId = draft.CategoryId,
listingDescription = draft.Description,
- listingPolicies,
+ listingPolicies = new
+ {
+ fulfillmentPolicyId = _fulfillmentPolicyId,
+ paymentPolicyId = _paymentPolicyId,
+ returnPolicyId = _returnPolicyId
+ },
pricingSummary = new
{
price = new { value = draft.Price.ToString("F2"), currency = "GBP" }
},
- merchantLocationKey = "home",
+ merchantLocationKey = _merchantLocationKey,
tax = new { vatPercentage = 0, applyTax = false }
};
@@ -127,39 +275,45 @@ public class EbayListingService
NullValueHandling = NullValueHandling.Ignore
});
- var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer";
- var response = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json"));
- var responseJson = await response.Content.ReadAsStringAsync();
+ using var req = MakeRequest(HttpMethod.Post,
+ $"{_auth.BaseUrl}/sell/inventory/v1/offer", token);
+ req.Content = new StringContent(json, Encoding.UTF8, "application/json");
- if (!response.IsSuccessStatusCode)
+ var res = await _http.SendAsync(req);
+ var responseJson = await res.Content.ReadAsStringAsync();
+
+ if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"Failed to create offer: {responseJson}");
- var obj = JObject.Parse(responseJson);
- return obj["offerId"]?.ToString()
+ return JObject.Parse(responseJson)["offerId"]?.ToString()
?? throw new InvalidOperationException("No offerId in create offer response.");
}
+ // ---- Publish ----
+
private async Task PublishOfferAsync(string offerId, string token)
{
- using var http = BuildClient(token);
- var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish";
- var response = await http.PostAsync(url, new StringContent("{}", Encoding.UTF8, "application/json"));
- var responseJson = await response.Content.ReadAsStringAsync();
+ using var req = MakeRequest(HttpMethod.Post,
+ $"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish", token);
+ req.Content = new StringContent("{}", Encoding.UTF8, "application/json");
- if (!response.IsSuccessStatusCode)
+ var res = await _http.SendAsync(req);
+ var responseJson = await res.Content.ReadAsStringAsync();
+
+ if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"Failed to publish offer: {responseJson}");
- var obj = JObject.Parse(responseJson);
- return obj["listingId"]?.ToString()
+ return JObject.Parse(responseJson)["listingId"]?.ToString()
?? throw new InvalidOperationException("No listingId in publish response.");
}
+ // ---- Photo upload ----
+
private async Task> UploadPhotosAsync(List photoPaths, string token)
{
var urls = new List();
if (photoPaths.Count == 0) return urls;
- // Use Trading API UploadSiteHostedPictures for each photo
var tradingBase = _auth.BaseUrl.Contains("sandbox")
? "https://api.sandbox.ebay.com/ws/api.dll"
: "https://api.ebay.com/ws/api.dll";
@@ -167,7 +321,6 @@ public class EbayListingService
foreach (var path in photoPaths.Take(12))
{
if (!File.Exists(path)) continue;
-
try
{
var url = await UploadSinglePhotoAsync(path, tradingBase, token);
@@ -176,7 +329,7 @@ public class EbayListingService
}
catch
{
- // Skip failed photo uploads, don't abort the whole listing
+ // Skip failed photos; don't abort the whole listing
}
}
@@ -186,8 +339,7 @@ public class EbayListingService
private async Task UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
{
var fileBytes = await File.ReadAllBytesAsync(filePath);
- var base64 = Convert.ToBase64String(fileBytes);
- var ext = Path.GetExtension(filePath).TrimStart('.').ToUpper();
+ var ext = Path.GetExtension(filePath).TrimStart('.').ToLower();
var soapBody = $"""
@@ -197,78 +349,39 @@ public class EbayListingService
{Path.GetFileNameWithoutExtension(filePath)}
Supersize
- https://example.com/placeholder.jpg
""";
- // For binary upload, use multipart
- using var http = new HttpClient();
- http.DefaultRequestHeaders.Add("X-EBAY-API-SITEID", "3");
- http.DefaultRequestHeaders.Add("X-EBAY-API-COMPATIBILITY-LEVEL", "967");
- http.DefaultRequestHeaders.Add("X-EBAY-API-CALL-NAME", "UploadSiteHostedPictures");
- http.DefaultRequestHeaders.Add("X-EBAY-API-IAF-TOKEN", token);
-
+ // Use HttpRequestMessage with _photoHttp so we don't create a new socket per photo
using var content = new MultipartFormDataContent();
content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload");
var imageContent = new ByteArrayContent(fileBytes);
- imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext.ToLower()}");
- content.Add(imageContent, "dummy", Path.GetFileName(filePath));
+ imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext}");
+ content.Add(imageContent, "image", Path.GetFileName(filePath));
- var response = await http.PostAsync(tradingUrl, content);
+ using var req = new HttpRequestMessage(HttpMethod.Post, tradingUrl);
+ req.Headers.Add("X-EBAY-API-SITEID", "3"); // UK site
+ req.Headers.Add("X-EBAY-API-COMPATIBILITY-LEVEL", "967");
+ req.Headers.Add("X-EBAY-API-CALL-NAME", "UploadSiteHostedPictures");
+ req.Headers.Add("X-EBAY-API-IAF-TOKEN", token);
+ req.Content = content;
+
+ var response = await _photoHttp.SendAsync(req);
var responseXml = await response.Content.ReadAsStringAsync();
- // Parse URL from XML response
var match = System.Text.RegularExpressions.Regex.Match(
responseXml, @"(.*?)");
return match.Success ? match.Groups[1].Value : null;
}
- private JObject BuildListingPolicies(ListingDraft draft)
- {
- var (serviceCode, costValue) = draft.Postage switch
- {
- PostageOption.RoyalMailFirstClass => ("UK_RoyalMailFirstClass", "1.50"),
- PostageOption.RoyalMailSecondClass => ("UK_RoyalMailSecondClass", "1.20"),
- PostageOption.RoyalMailTracked24 => ("UK_RoyalMailTracked24", "2.95"),
- PostageOption.RoyalMailTracked48 => ("UK_RoyalMailTracked48", "2.50"),
- PostageOption.FreePostage => ("UK_RoyalMailSecondClass", "0.00"),
- _ => ("UK_CollectionInPerson", "0.00")
- };
+ // ---- Helpers ----
- return new JObject
- {
- ["shippingPolicyName"] = "Default",
- ["paymentPolicyName"] = "Default",
- ["returnPolicyName"] = "Default",
- ["shippingCostType"] = "FLAT_RATE",
- ["shippingOptions"] = new JArray
- {
- new JObject
- {
- ["optionType"] = "DOMESTIC",
- ["costType"] = "FLAT_RATE",
- ["shippingServices"] = new JArray
- {
- new JObject
- {
- ["shippingServiceCode"] = serviceCode,
- ["shippingCost"] = new JObject
- {
- ["value"] = costValue,
- ["currency"] = "GBP"
- }
- }
- }
- }
- }
- };
- }
-
- private HttpClient BuildClient(string token)
+ /// Creates a pre-authorised request targeting the eBay REST APIs.
+ private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token)
{
- var http = new HttpClient();
- http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
- http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
- return http;
+ var req = new HttpRequestMessage(method, url);
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ req.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
+ return req;
}
}
diff --git a/EbayListingTool/Views/MainWindow.xaml.cs b/EbayListingTool/Views/MainWindow.xaml.cs
index 6e4bde5..131bd7a 100644
--- a/EbayListingTool/Views/MainWindow.xaml.cs
+++ b/EbayListingTool/Views/MainWindow.xaml.cs
@@ -69,6 +69,7 @@ public partial class MainWindow : MetroWindow
private void DisconnectBtn_Click(object sender, RoutedEventArgs e)
{
_auth.Disconnect();
+ _listingService.ClearCache(); // clear cached policy/location IDs for next login
UpdateConnectionState();
SetStatus("Disconnected from eBay.");
}